From 898f4e2271aaf2bbcbfc735314f034ceddf2be2c Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 10 Nov 2022 14:07:38 +0100 Subject: [PATCH 01/14] Add support for transactions Co-authored-by: klinson Co-authored-by: levon80999 --- README.md | 41 +++ phpunit.xml.dist | 3 + src/Concerns/ManagesTransactions.php | 105 +++++++ src/Connection.php | 3 + src/Query/Builder.php | 47 ++- tests/TransactionTest.php | 430 +++++++++++++++++++++++++++ 6 files changed, 619 insertions(+), 10 deletions(-) create mode 100644 src/Concerns/ManagesTransactions.php create mode 100644 tests/TransactionTest.php diff --git a/README.md b/README.md index 06531dcb1..887f39d26 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ This package adds functionalities to the Eloquent model and Query builder for Mo - [Query Builder](#query-builder) - [Basic Usage](#basic-usage-2) - [Available operations](#available-operations) + - [Transactions](#transactions) - [Schema](#schema) - [Basic Usage](#basic-usage-3) - [Geospatial indexes](#geospatial-indexes) @@ -968,6 +969,46 @@ If you are familiar with [Eloquent Queries](http://laravel.com/docs/queries), th ### Available operations To see the available operations, check the [Eloquent](#eloquent) section. +Transactions +------- +Transactions require MongoDB version ^4.0 as well as deployment of replica set or sharded clusters. You can find more information [in the MongoDB docs](https://docs.mongodb.com/manual/core/transactions/) + +### Basic Usage + +Transaction supports all operations. + +```php +DB::transaction(function () { + User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => 'klinsonup@gmail.com']); + DB::collection('users')->where('name', 'john')->update(['age' => 20]); + DB::collection('users')->where('name', 'john')->delete(); +}); +``` + +```php +// begin a transaction +DB::beginTransaction(); +User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => 'klinsonup@gmail.com']); +DB::collection('users')->where('name', 'john')->update(['age' => 20]); +DB::collection('users')->where('name', 'john')->delete(); + +// you can commit your changes +DB::commit(); + +// you can also rollback them +//DB::rollBack(); +``` +**NOTE:** Transactions in MongoDB cannot be nested. DB::beginTransaction() function will start new transactions in a new created or existing session and will raise the RuntimeException when transactions already exist. See more in MongoDB official docs [Transactions and Sessions](https://www.mongodb.com/docs/manual/core/transactions/#transactions-and-sessions) +```php +// This code will raise a RuntimeException +DB::beginTransaction(); + User::create(['name' => 'john', 'age' => 20, 'title' => 'admin']); + DB::beginTransaction() + DB::collection('users')->where('name', 'john')->update(['age' => 20]); + DB::commit() +DB::rollBack(); +``` + Schema ------ The database driver also has (limited) schema builder support. You can easily manipulate collections and set indexes. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 15601b8dc..696c5ef0b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,6 +19,9 @@ tests/QueryBuilderTest.php tests/QueryTest.php + + tests/TransactionTest.php + tests/ModelTest.php tests/RelationsTest.php diff --git a/src/Concerns/ManagesTransactions.php b/src/Concerns/ManagesTransactions.php new file mode 100644 index 000000000..ee714c6a5 --- /dev/null +++ b/src/Concerns/ManagesTransactions.php @@ -0,0 +1,105 @@ +session; + } + + private function getSessionOrCreate(): Session + { + if ($this->session === null) { + $this->session = $this->getMongoClient()->startSession(); + } + + return $this->session; + } + + private function getSessionOrThrow(): Session + { + $session = $this->getSession(); + + if ($session === null) { + throw new RuntimeException('There is no active session.'); + } + + return $session; + } + + /** + * Use the existing or create new session and start a transaction in session. + * + * In version 4.0, MongoDB supports multi-document transactions on replica sets. + * In version 4.2, MongoDB introduces distributed transactions, which adds support for multi-document transactions on sharded clusters and incorporates the existing support for multi-document transactions on replica sets. + * + * @see https://docs.mongodb.com/manual/core/transactions/ + */ + public function beginTransaction(array $options = []): void + { + $this->getSessionOrCreate()->startTransaction($options); + $this->transactions = 1; + } + + /** + * Commit transaction in this session. + */ + public function commit(): void + { + $this->getSessionOrThrow()->commitTransaction(); + $this->transactions = 0; + } + + /** + * Rollback transaction in this session. + */ + public function rollBack($toLevel = null): void + { + $this->getSessionOrThrow()->abortTransaction(); + $this->transactions = 0; + } + + /** + * Static transaction function realize the with_transaction functionality provided by MongoDB. + * + * @param int $attempts + */ + public function transaction(Closure $callback, $attempts = 1, array $options = []): mixed + { + $attemptsLeft = $attempts; + $callbackResult = null; + + $session = $this->getSessionOrCreate(); + + $callbackFunction = function (Session $session) use ($callback, &$attemptsLeft, &$callbackResult) { + $attemptsLeft--; + + if ($attemptsLeft < 0) { + $session->abortTransaction(); + + return; + } + + $callbackResult = $callback(); + }; + + with_transaction($session, $callbackFunction, $options); + + return $callbackResult; + } +} diff --git a/src/Connection.php b/src/Connection.php index b65b40ca3..c78ac95c1 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -5,11 +5,14 @@ use Illuminate\Database\Connection as BaseConnection; use Illuminate\Support\Arr; use InvalidArgumentException; +use Jenssegers\Mongodb\Concerns\ManagesTransactions; use MongoDB\Client; use MongoDB\Database; class Connection extends BaseConnection { + use ManagesTransactions; + /** * The MongoDB database handler. * diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 631e64950..cb305ec09 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -346,6 +346,8 @@ public function getFresh($columns = [], $returnLazy = false) $options = array_merge($options, $this->options); } + $options = $this->inheritConnectionOptions($options); + // Execute aggregation $results = iterator_to_array($this->collection->aggregate($pipeline, $options)); @@ -356,12 +358,10 @@ public function getFresh($columns = [], $returnLazy = false) // Return distinct results directly $column = isset($this->columns[0]) ? $this->columns[0] : '_id'; + $options = $this->inheritConnectionOptions(); + // Execute distinct - if ($wheres) { - $result = $this->collection->distinct($column, $wheres); - } else { - $result = $this->collection->distinct($column); - } + $result = $this->collection->distinct($column, $wheres ?: [], $options); return new Collection($result); } // Normal query @@ -407,6 +407,8 @@ public function getFresh($columns = [], $returnLazy = false) $options = array_merge($options, $this->options); } + $options = $this->inheritConnectionOptions($options); + // Execute query and get MongoCursor $cursor = $this->collection->find($wheres, $options); @@ -581,8 +583,9 @@ public function insert(array $values) $values = [$values]; } - // Batch insert - $result = $this->collection->insertMany($values); + $options = $this->inheritConnectionOptions(); + + $result = $this->collection->insertMany($values, $options); return 1 == (int) $result->isAcknowledged(); } @@ -592,7 +595,9 @@ public function insert(array $values) */ public function insertGetId(array $values, $sequence = null) { - $result = $this->collection->insertOne($values); + $options = $this->inheritConnectionOptions(); + + $result = $this->collection->insertOne($values, $options); if (1 == (int) $result->isAcknowledged()) { if ($sequence === null) { @@ -614,6 +619,8 @@ public function update(array $values, array $options = []) $values = ['$set' => $values]; } + $options = $this->inheritConnectionOptions($options); + return $this->performUpdate($values, $options); } @@ -635,6 +642,8 @@ public function increment($column, $amount = 1, array $extra = [], array $option $query->orWhereNotNull($column); }); + $options = $this->inheritConnectionOptions($options); + return $this->performUpdate($query, $options); } @@ -696,7 +705,10 @@ public function delete($id = null) } $wheres = $this->compileWheres(); - $result = $this->collection->DeleteMany($wheres); + $options = $this->inheritConnectionOptions(); + + $result = $this->collection->deleteMany($wheres, $options); + if (1 == (int) $result->isAcknowledged()) { return $result->getDeletedCount(); } @@ -721,7 +733,8 @@ public function from($collection, $as = null) */ public function truncate(): bool { - $result = $this->collection->deleteMany([]); + $options = $this->inheritConnectionOptions(); + $result = $this->collection->deleteMany($options); return 1 === (int) $result->isAcknowledged(); } @@ -855,6 +868,8 @@ protected function performUpdate($query, array $options = []) $options['multiple'] = true; } + $options = $this->inheritConnectionOptions($options); + $wheres = $this->compileWheres(); $result = $this->collection->UpdateMany($wheres, $query, $options); if (1 == (int) $result->isAcknowledged()) { @@ -1249,6 +1264,18 @@ public function options(array $options) return $this; } + /** + * Apply the connection's session to options if it's not already specified. + */ + private function inheritConnectionOptions(array $options = []): array + { + if (! isset($options['session']) && ($session = $this->connection->getSession())) { + $options['session'] = $session; + } + + return $options; + } + /** * @inheritdoc */ diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php new file mode 100644 index 000000000..2225027d7 --- /dev/null +++ b/tests/TransactionTest.php @@ -0,0 +1,430 @@ +getPrimaryServerType() === Server::TYPE_STANDALONE) { + $this->markTestSkipped('Transactions are not supported on standalone servers'); + } + + User::truncate(); + } + + public function tearDown(): void + { + User::truncate(); + + parent::tearDown(); + } + + public function testCreateWithCommit(): void + { + DB::beginTransaction(); + /** @var User $klinson */ + $klinson = User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + DB::commit(); + + $this->assertInstanceOf(Model::class, $klinson); + $this->assertTrue($klinson->exists); + $this->assertEquals('klinson', $klinson->name); + + $check = User::find($klinson->_id); + $this->assertInstanceOf(User::class, $check); + $this->assertEquals($klinson->name, $check->name); + } + + public function testCreateRollBack(): void + { + DB::beginTransaction(); + /** @var User $klinson */ + $klinson = User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + DB::rollBack(); + + $this->assertInstanceOf(Model::class, $klinson); + $this->assertTrue($klinson->exists); + $this->assertEquals('klinson', $klinson->name); + + $this->assertFalse(User::where('_id', $klinson->_id)->exists()); + } + + public function testInsertWithCommit(): void + { + DB::beginTransaction(); + DB::collection('users')->insert(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + DB::commit(); + + $this->assertTrue(DB::collection('users')->where('name', 'klinson')->exists()); + } + + public function testInsertWithRollBack(): void + { + DB::beginTransaction(); + DB::collection('users')->insert(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + DB::rollBack(); + + $this->assertFalse(DB::collection('users')->where('name', 'klinson')->exists()); + } + + public function testEloquentCreateWithCommit(): void + { + DB::beginTransaction(); + /** @var User $klinson */ + $klinson = User::getModel(); + $klinson->name = 'klinson'; + $klinson->save(); + DB::commit(); + + $this->assertTrue($klinson->exists); + $this->assertNotNull($klinson->getIdAttribute()); + + $check = User::find($klinson->_id); + $this->assertInstanceOf(User::class, $check); + $this->assertEquals($check->name, $klinson->name); + } + + public function testEloquentCreateWithRollBack(): void + { + DB::beginTransaction(); + /** @var User $klinson */ + $klinson = User::getModel(); + $klinson->name = 'klinson'; + $klinson->save(); + DB::rollBack(); + + $this->assertTrue($klinson->exists); + $this->assertNotNull($klinson->getIdAttribute()); + + $this->assertFalse(User::where('_id', $klinson->_id)->exists()); + } + + public function testInsertGetIdWithCommit(): void + { + DB::beginTransaction(); + $userId = DB::collection('users')->insertGetId(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + DB::commit(); + + $this->assertInstanceOf(ObjectId::class, $userId); + + $user = DB::collection('users')->find((string) $userId); + $this->assertEquals('klinson', $user['name']); + } + + public function testInsertGetIdWithRollBack(): void + { + DB::beginTransaction(); + $userId = DB::collection('users')->insertGetId(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + DB::rollBack(); + + $this->assertInstanceOf(ObjectId::class, $userId); + $this->assertFalse(DB::collection('users')->where('_id', (string) $userId)->exists()); + } + + public function testUpdateWithCommit(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + $updated = DB::collection('users')->where('name', 'klinson')->update(['age' => 21]); + DB::commit(); + + $this->assertEquals(1, $updated); + $this->assertTrue(DB::collection('users')->where('name', 'klinson')->where('age', 21)->exists()); + } + + public function testUpdateWithRollback(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + $updated = DB::collection('users')->where('name', 'klinson')->update(['age' => 21]); + DB::rollBack(); + + $this->assertEquals(1, $updated); + $this->assertFalse(DB::collection('users')->where('name', 'klinson')->where('age', 21)->exists()); + } + + public function testEloquentUpdateWithCommit(): void + { + /** @var User $klinson */ + $klinson = User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + /** @var User $alcaeus */ + $alcaeus = User::create(['name' => 'alcaeus', 'age' => 38, 'title' => 'admin']); + + DB::beginTransaction(); + $klinson->age = 21; + $klinson->update(); + + $alcaeus->update(['age' => 39]); + DB::commit(); + + $this->assertEquals(21, $klinson->age); + $this->assertEquals(39, $alcaeus->age); + + $this->assertTrue(User::where('_id', $klinson->_id)->where('age', 21)->exists()); + $this->assertTrue(User::where('_id', $alcaeus->_id)->where('age', 39)->exists()); + } + + public function testEloquentUpdateWithRollBack(): void + { + /** @var User $klinson */ + $klinson = User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + /** @var User $alcaeus */ + $alcaeus = User::create(['name' => 'klinson', 'age' => 38, 'title' => 'admin']); + + DB::beginTransaction(); + $klinson->age = 21; + $klinson->update(); + + $alcaeus->update(['age' => 39]); + DB::rollBack(); + + $this->assertEquals(21, $klinson->age); + $this->assertEquals(39, $alcaeus->age); + + $this->assertFalse(User::where('_id', $klinson->_id)->where('age', 21)->exists()); + $this->assertFalse(User::where('_id', $alcaeus->_id)->where('age', 39)->exists()); + } + + public function testDeleteWithCommit(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + $deleted = User::where(['name' => 'klinson'])->delete(); + DB::commit(); + + $this->assertEquals(1, $deleted); + $this->assertFalse(User::where(['name' => 'klinson'])->exists()); + } + + public function testDeleteWithRollBack(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + $deleted = User::where(['name' => 'klinson'])->delete(); + DB::rollBack(); + + $this->assertEquals(1, $deleted); + $this->assertTrue(User::where(['name' => 'klinson'])->exists()); + } + + public function testEloquentDeleteWithCommit(): void + { + /** @var User $klinson */ + $klinson = User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + $klinson->delete(); + DB::commit(); + + $this->assertFalse(User::where('_id', $klinson->_id)->exists()); + } + + public function testEloquentDeleteWithRollBack(): void + { + /** @var User $klinson */ + $klinson = User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + $klinson->delete(); + DB::rollBack(); + + $this->assertTrue(User::where('_id', $klinson->_id)->exists()); + } + + public function testIncrementWithCommit(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + DB::collection('users')->where('name', 'klinson')->increment('age'); + DB::commit(); + + $this->assertTrue(DB::collection('users')->where('name', 'klinson')->where('age', 21)->exists()); + } + + public function testIncrementWithRollBack(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + DB::collection('users')->where('name', 'klinson')->increment('age'); + DB::rollBack(); + + $this->assertTrue(DB::collection('users')->where('name', 'klinson')->where('age', 20)->exists()); + } + + public function testDecrementWithCommit(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + DB::collection('users')->where('name', 'klinson')->decrement('age'); + DB::commit(); + + $this->assertTrue(DB::collection('users')->where('name', 'klinson')->where('age', 19)->exists()); + } + + public function testDecrementWithRollBack(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::beginTransaction(); + DB::collection('users')->where('name', 'klinson')->decrement('age'); + DB::rollBack(); + + $this->assertTrue(DB::collection('users')->where('name', 'klinson')->where('age', 20)->exists()); + } + + public function testQuery() + { + /** rollback test */ + DB::beginTransaction(); + $count = DB::collection('users')->count(); + $this->assertEquals(0, $count); + DB::collection('users')->insert(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + $count = DB::collection('users')->count(); + $this->assertEquals(1, $count); + DB::rollBack(); + + $count = DB::collection('users')->count(); + $this->assertEquals(0, $count); + + /** commit test */ + DB::beginTransaction(); + $count = DB::collection('users')->count(); + $this->assertEquals(0, $count); + DB::collection('users')->insert(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + $count = DB::collection('users')->count(); + $this->assertEquals(1, $count); + DB::commit(); + + $count = DB::collection('users')->count(); + $this->assertEquals(1, $count); + } + + public function testTransaction(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + DB::transaction(function (): void { + User::create(['name' => 'alcaeus', 'age' => 38, 'title' => 'admin']); + User::where(['name' => 'klinson'])->update(['age' => 21]); + }); + + $count = User::count(); + $this->assertEquals(2, $count); + + $this->assertTrue(User::where('alcaeus')->exists()); + $this->assertTrue(User::where(['name' => 'klinson'])->where('age', 21)->exists()); + } + + public function testTransactionRepeatsOnTransientFailure(): void + { + User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + $timesRun = 0; + + DB::transaction(function () use (&$timesRun): void { + User::where(['name' => 'klinson'])->update(['age' => 21]); + + // Update user during transaction to simulate a simultaneous update + if ($timesRun == 0) { + DB::getCollection('users')->updateOne(['name' => 'klinson'], ['$set' => ['age' => 22]]); + } + + $timesRun++; + }, 2); + + $this->assertSame(2, $timesRun); + $this->assertTrue(User::where(['name' => 'klinson'])->where('age', 21)->exists()); + } + + public function testTransactionRespectsRepetitionLimit(): void + { + $klinson = User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + + $timesRun = 0; + + DB::transaction(function () use (&$timesRun): void { + User::where(['name' => 'klinson'])->update(['age' => 21]); + + // Update user during transaction to simulate a simultaneous update + DB::getCollection('users')->updateOne(['name' => 'klinson'], ['$inc' => ['age' => 2]]); + + $timesRun++; + }, 2); + + $this->assertSame(2, $timesRun); + + $check = User::find($klinson->_id); + $this->assertInstanceOf(User::class, $check); + + // Age is expected to be 24: the callback is executed twice, incrementing age by 2 every time + $this->assertSame(24, $check->age); + } + + public function testTransactionReturnsCallbackResult(): void + { + $result = DB::transaction(function (): User { + return User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + }); + + $this->assertInstanceOf(User::class, $result); + $this->assertEquals($result->title, 'admin'); + $this->assertSame(1, User::count()); + } + + public function testNestedTransactionsCauseException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Transaction already in progress'); + + DB::beginTransaction(); + DB::beginTransaction(); + DB::commit(); + DB::rollBack(); + } + + public function testNestingTransactionInManualTransaction() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Transaction already in progress'); + + DB::beginTransaction(); + DB::transaction(function (): void { + }); + DB::rollBack(); + } + + public function testCommitWithoutSession(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('There is no active session.'); + + DB::commit(); + } + + public function testRollBackWithoutSession(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('There is no active session.'); + + DB::rollback(); + } + + private function getPrimaryServerType(): int + { + return DB::getMongoClient()->getManager()->selectServer(new ReadPreference(ReadPreference::RP_PRIMARY))->getType(); + } +} From e68124cf934ad970dc9181e645ed69a342cbe257 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 10 Nov 2022 14:24:52 +0100 Subject: [PATCH 02/14] Start single-member replica set in CI Co-authored-by: levon80999 --- .github/workflows/build-ci.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 6f57f015d..7d4b9f223 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -44,10 +44,6 @@ jobs: - '8.0' - '8.1' services: - mongo: - image: mongo:${{ matrix.mongodb }} - ports: - - 27017:27017 mysql: image: mysql:5.7 ports: @@ -59,6 +55,13 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Create MongoDB Replica Set + run: | + docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs + until docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "db.serverStatus()"; do + sleep 1 + done + sudo docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" - name: "Installing php" uses: shivammathur/setup-php@v2 with: @@ -88,7 +91,7 @@ jobs: run: | ./vendor/bin/phpunit --coverage-clover coverage.xml env: - MONGODB_URI: 'mongodb://127.0.0.1/' + MONGODB_URI: 'mongodb://127.0.0.1/?replicaSet=rs' MYSQL_HOST: 0.0.0.0 MYSQL_PORT: 3307 - uses: codecov/codecov-action@v1 From eea99438527404f6450c25f5836e8c3ee909fa94 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 14 Nov 2022 09:25:19 +0100 Subject: [PATCH 03/14] Add connection options for faster failures in tests The faster connection and server selection timeouts ensure we don't spend too much time waiting for the inevitable as we're expecting fast connections on CI systems Co-authored-by: levon80999 --- tests/config/database.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/config/database.php b/tests/config/database.php index 73f3d8697..4951572e7 100644 --- a/tests/config/database.php +++ b/tests/config/database.php @@ -7,6 +7,10 @@ 'driver' => 'mongodb', 'dsn' => env('MONGODB_URI', 'mongodb://127.0.0.1/'), 'database' => env('MONGO_DATABASE', 'unittest'), + 'options' => [ + 'connectTimeoutMS' => 100, + 'serverSelectionTimeoutMS' => 250, + ], ], 'mysql' => [ From 0a2e60571569783e9b9fb1e3dff3f86020a0aa51 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 15 Nov 2022 12:08:29 +0100 Subject: [PATCH 04/14] Apply readme code review suggestions --- README.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 887f39d26..0c07e7288 100644 --- a/README.md +++ b/README.md @@ -970,16 +970,14 @@ If you are familiar with [Eloquent Queries](http://laravel.com/docs/queries), th To see the available operations, check the [Eloquent](#eloquent) section. Transactions -------- +------------ Transactions require MongoDB version ^4.0 as well as deployment of replica set or sharded clusters. You can find more information [in the MongoDB docs](https://docs.mongodb.com/manual/core/transactions/) ### Basic Usage -Transaction supports all operations. - ```php DB::transaction(function () { - User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => 'klinsonup@gmail.com']); + User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => 'john@example.com']); DB::collection('users')->where('name', 'john')->update(['age' => 20]); DB::collection('users')->where('name', 'john')->delete(); }); @@ -988,24 +986,32 @@ DB::transaction(function () { ```php // begin a transaction DB::beginTransaction(); -User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => 'klinsonup@gmail.com']); +User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => 'john@example.com']); DB::collection('users')->where('name', 'john')->update(['age' => 20]); DB::collection('users')->where('name', 'john')->delete(); -// you can commit your changes +// commit changes DB::commit(); +``` + +To abort a transaction, call the `rollBack` method at any point during the transaction: +```php +DB::beginTransaction(); +User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => 'john@example.com']); -// you can also rollback them -//DB::rollBack(); +// Abort the transaction, discarding any data created as part of it +DB::rollBack(); ``` + **NOTE:** Transactions in MongoDB cannot be nested. DB::beginTransaction() function will start new transactions in a new created or existing session and will raise the RuntimeException when transactions already exist. See more in MongoDB official docs [Transactions and Sessions](https://www.mongodb.com/docs/manual/core/transactions/#transactions-and-sessions) ```php -// This code will raise a RuntimeException DB::beginTransaction(); - User::create(['name' => 'john', 'age' => 20, 'title' => 'admin']); - DB::beginTransaction() - DB::collection('users')->where('name', 'john')->update(['age' => 20]); - DB::commit() +User::create(['name' => 'john', 'age' => 20, 'title' => 'admin']); + +// This call to start a nested transaction will raise a RuntimeException +DB::beginTransaction(); +DB::collection('users')->where('name', 'john')->update(['age' => 20]); +DB::commit(); DB::rollBack(); ``` From 203160e6630245d82c511eca8c775cb7cac7ad0b Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 15 Nov 2022 12:08:40 +0100 Subject: [PATCH 05/14] Simplify replica set creation in CI --- .github/workflows/build-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 7d4b9f223..4b7066cbb 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -58,10 +58,10 @@ jobs: - name: Create MongoDB Replica Set run: | docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs - until docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "db.serverStatus()"; do + until docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do sleep 1 done - sudo docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" + sudo docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "rs.initiate()" - name: "Installing php" uses: shivammathur/setup-php@v2 with: From 8fc6915902cc71316198a361866ac2a8fde3bd1e Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 15 Nov 2022 13:42:58 +0100 Subject: [PATCH 06/14] Apply feedback from code review --- src/Concerns/ManagesTransactions.php | 14 +++++++------- src/Query/Builder.php | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Concerns/ManagesTransactions.php b/src/Concerns/ManagesTransactions.php index ee714c6a5..a145308dd 100644 --- a/src/Concerns/ManagesTransactions.php +++ b/src/Concerns/ManagesTransactions.php @@ -8,10 +8,15 @@ use MongoDB\Driver\Session; use function MongoDB\with_transaction; +/** + * @see https://docs.mongodb.com/manual/core/transactions/ + */ trait ManagesTransactions { protected ?Session $session = null; + protected $transactions = 0; + /** * @return Client */ @@ -43,12 +48,7 @@ private function getSessionOrThrow(): Session } /** - * Use the existing or create new session and start a transaction in session. - * - * In version 4.0, MongoDB supports multi-document transactions on replica sets. - * In version 4.2, MongoDB introduces distributed transactions, which adds support for multi-document transactions on sharded clusters and incorporates the existing support for multi-document transactions on replica sets. - * - * @see https://docs.mongodb.com/manual/core/transactions/ + * Starts a transaction on the active session. An active session will be created if none exists. */ public function beginTransaction(array $options = []): void { @@ -66,7 +66,7 @@ public function commit(): void } /** - * Rollback transaction in this session. + * Abort transaction in this session. */ public function rollBack($toLevel = null): void { diff --git a/src/Query/Builder.php b/src/Query/Builder.php index cb305ec09..066412734 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -734,7 +734,7 @@ public function from($collection, $as = null) public function truncate(): bool { $options = $this->inheritConnectionOptions(); - $result = $this->collection->deleteMany($options); + $result = $this->collection->deleteMany([], $options); return 1 === (int) $result->isAcknowledged(); } From 2c549c87391098ba5df87573d9eb03ac914746f5 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 15 Nov 2022 13:49:25 +0100 Subject: [PATCH 07/14] Update naming of database env variable in tests --- phpunit.xml.dist | 2 +- tests/config/database.php | 2 +- tests/config/queue.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 696c5ef0b..9aebe0c0a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -39,7 +39,7 @@ - + diff --git a/tests/config/database.php b/tests/config/database.php index 4951572e7..498e4e7e0 100644 --- a/tests/config/database.php +++ b/tests/config/database.php @@ -6,7 +6,7 @@ 'name' => 'mongodb', 'driver' => 'mongodb', 'dsn' => env('MONGODB_URI', 'mongodb://127.0.0.1/'), - 'database' => env('MONGO_DATABASE', 'unittest'), + 'database' => env('MONGODB_DATABASE', 'unittest'), 'options' => [ 'connectTimeoutMS' => 100, 'serverSelectionTimeoutMS' => 250, diff --git a/tests/config/queue.php b/tests/config/queue.php index 7d52487fa..d287780e9 100644 --- a/tests/config/queue.php +++ b/tests/config/queue.php @@ -16,7 +16,7 @@ ], 'failed' => [ - 'database' => env('MONGO_DATABASE'), + 'database' => env('MONGODB_DATABASE'), 'driver' => 'mongodb', 'table' => 'failed_jobs', ], From a6aa3cc79e527f0549058ea79c7b6e55e371bc74 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 15 Nov 2022 13:57:17 +0100 Subject: [PATCH 08/14] Use default argument for server selection (which defaults to primary) --- tests/TransactionTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php index 2225027d7..aae4fb7e6 100644 --- a/tests/TransactionTest.php +++ b/tests/TransactionTest.php @@ -3,7 +3,6 @@ use Illuminate\Support\Facades\DB; use Jenssegers\Mongodb\Eloquent\Model; use MongoDB\BSON\ObjectId; -use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; class TransactionTest extends TestCase @@ -425,6 +424,6 @@ public function testRollBackWithoutSession(): void private function getPrimaryServerType(): int { - return DB::getMongoClient()->getManager()->selectServer(new ReadPreference(ReadPreference::RP_PRIMARY))->getType(); + return DB::getMongoClient()->getManager()->selectServer()->getType(); } } From 2a82a543bb5d1888df5b7a24df525bebcc27b100 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 22 Nov 2022 09:51:14 +0100 Subject: [PATCH 09/14] Revert "Simplify replica set creation in CI" This partially reverts commit 203160e6630245d82c511eca8c775cb7cac7ad0b. The simplified call unfortunately breaks tests. --- .github/workflows/build-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 4b7066cbb..d02318f8f 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -61,7 +61,7 @@ jobs: until docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do sleep 1 done - sudo docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "rs.initiate()" + sudo docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" - name: "Installing php" uses: shivammathur/setup-php@v2 with: From bb58fcb97e5a2da27c21930d95f5da9498a0dd45 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 23 Nov 2022 10:52:30 +0100 Subject: [PATCH 10/14] Pass connection instance to transactional closure This is consistent with the behaviour of the original ManagesTransactions concern. --- src/Concerns/ManagesTransactions.php | 6 ++---- tests/TransactionTest.php | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Concerns/ManagesTransactions.php b/src/Concerns/ManagesTransactions.php index a145308dd..d06d2a2d9 100644 --- a/src/Concerns/ManagesTransactions.php +++ b/src/Concerns/ManagesTransactions.php @@ -84,8 +84,6 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [ $attemptsLeft = $attempts; $callbackResult = null; - $session = $this->getSessionOrCreate(); - $callbackFunction = function (Session $session) use ($callback, &$attemptsLeft, &$callbackResult) { $attemptsLeft--; @@ -95,10 +93,10 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [ return; } - $callbackResult = $callback(); + $callbackResult = $callback($this); }; - with_transaction($session, $callbackFunction, $options); + with_transaction($this->getSessionOrCreate(), $callbackFunction, $options); return $callbackResult; } diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php index aae4fb7e6..53b668360 100644 --- a/tests/TransactionTest.php +++ b/tests/TransactionTest.php @@ -1,6 +1,7 @@ 'klinson', 'age' => 20, 'title' => 'admin']); - DB::transaction(function (): void { + DB::transaction(function (Connection $connection): void { User::create(['name' => 'alcaeus', 'age' => 38, 'title' => 'admin']); User::where(['name' => 'klinson'])->update(['age' => 21]); }); From df3a53ae513e95802e4d99a4988bb282e3303be3 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 24 Nov 2022 09:31:07 +0100 Subject: [PATCH 11/14] Correctly re-throw exception when callback attempts have been exceeded. --- src/Concerns/ManagesTransactions.php | 17 +++++++++++-- tests/TransactionTest.php | 38 ++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/Concerns/ManagesTransactions.php b/src/Concerns/ManagesTransactions.php index d06d2a2d9..9fab6fca6 100644 --- a/src/Concerns/ManagesTransactions.php +++ b/src/Concerns/ManagesTransactions.php @@ -7,6 +7,7 @@ use MongoDB\Driver\Exception\RuntimeException; use MongoDB\Driver\Session; use function MongoDB\with_transaction; +use Throwable; /** * @see https://docs.mongodb.com/manual/core/transactions/ @@ -83,8 +84,9 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [ { $attemptsLeft = $attempts; $callbackResult = null; + $throwable = null; - $callbackFunction = function (Session $session) use ($callback, &$attemptsLeft, &$callbackResult) { + $callbackFunction = function (Session $session) use ($callback, &$attemptsLeft, &$callbackResult, &$throwable) { $attemptsLeft--; if ($attemptsLeft < 0) { @@ -93,11 +95,22 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [ return; } - $callbackResult = $callback($this); + // Catch, store and re-throw any exception thrown during execution + // of the callable. The last exception is re-thrown if the transaction + // was aborted because the number of callback attempts has been exceeded. + try { + $callbackResult = $callback($this); + } catch (Throwable $throwable) { + throw $throwable; + } }; with_transaction($this->getSessionOrCreate(), $callbackFunction, $options); + if ($attemptsLeft < 0 && $throwable) { + throw $throwable; + } + return $callbackResult; } } diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php index 53b668360..52ce422a7 100644 --- a/tests/TransactionTest.php +++ b/tests/TransactionTest.php @@ -4,6 +4,7 @@ use Jenssegers\Mongodb\Connection; use Jenssegers\Mongodb\Eloquent\Model; use MongoDB\BSON\ObjectId; +use MongoDB\Driver\Exception\BulkWriteException; use MongoDB\Driver\Server; class TransactionTest extends TestCase @@ -317,6 +318,8 @@ public function testTransaction(): void { User::create(['name' => 'klinson', 'age' => 20, 'title' => 'admin']); + // The $connection parameter may be unused, but is implicitly used to + // test that the closure is executed with the connection as an argument. DB::transaction(function (Connection $connection): void { User::create(['name' => 'alcaeus', 'age' => 38, 'title' => 'admin']); User::where(['name' => 'klinson'])->update(['age' => 21]); @@ -336,14 +339,18 @@ public function testTransactionRepeatsOnTransientFailure(): void $timesRun = 0; DB::transaction(function () use (&$timesRun): void { - User::where(['name' => 'klinson'])->update(['age' => 21]); + $timesRun++; + + // Run a query to start the transaction on the server + User::create(['name' => 'alcaeus', 'age' => 38, 'title' => 'admin']); - // Update user during transaction to simulate a simultaneous update - if ($timesRun == 0) { + // Update user outside of the session + if ($timesRun == 1) { DB::getCollection('users')->updateOne(['name' => 'klinson'], ['$set' => ['age' => 22]]); } - $timesRun++; + // This update will create a write conflict, aborting the transaction + User::where(['name' => 'klinson'])->update(['age' => 21]); }, 2); $this->assertSame(2, $timesRun); @@ -356,14 +363,25 @@ public function testTransactionRespectsRepetitionLimit(): void $timesRun = 0; - DB::transaction(function () use (&$timesRun): void { - User::where(['name' => 'klinson'])->update(['age' => 21]); + try { + DB::transaction(function () use (&$timesRun): void { + $timesRun++; - // Update user during transaction to simulate a simultaneous update - DB::getCollection('users')->updateOne(['name' => 'klinson'], ['$inc' => ['age' => 2]]); + // Run a query to start the transaction on the server + User::create(['name' => 'alcaeus', 'age' => 38, 'title' => 'admin']); - $timesRun++; - }, 2); + // Update user outside of the session + DB::getCollection('users')->updateOne(['name' => 'klinson'], ['$inc' => ['age' => 2]]); + + // This update will create a write conflict, aborting the transaction + User::where(['name' => 'klinson'])->update(['age' => 21]); + }, 2); + + $this->fail('Expected exception during transaction'); + } catch (BulkWriteException $e) { + $this->assertInstanceOf(BulkWriteException::class, $e); + $this->assertStringContainsString('WriteConflict', $e->getMessage()); + } $this->assertSame(2, $timesRun); From f2510b119d906511a9fe1e42a6f585b311bd595f Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 24 Nov 2022 09:55:48 +0100 Subject: [PATCH 12/14] Limit transaction lifetime to 5 seconds This ensures that hung transactions don't block any subsequent operations for an unnecessary period of time. --- .github/workflows/build-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index d02318f8f..2aa52d60e 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -57,7 +57,7 @@ jobs: - uses: actions/checkout@v2 - name: Create MongoDB Replica Set run: | - docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs + docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5 until docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do sleep 1 done From 8afe1059be6186bd2e5579cf6678de9a476a2f5a Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 24 Nov 2022 09:56:18 +0100 Subject: [PATCH 13/14] Add build step to print MongoDB server status --- .github/workflows/build-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 2aa52d60e..f081e3273 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -62,6 +62,9 @@ jobs: sleep 1 done sudo docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" + - name: Show MongoDB server status + run: | + docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "db.runCommand({ serverStatus: 1 })" - name: "Installing php" uses: shivammathur/setup-php@v2 with: From 7a644bc80cda036e05025ee8871c054a84121f9b Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 24 Nov 2022 13:55:51 +0100 Subject: [PATCH 14/14] Update src/Concerns/ManagesTransactions.php Co-authored-by: Jeremy Mikola --- src/Concerns/ManagesTransactions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Concerns/ManagesTransactions.php b/src/Concerns/ManagesTransactions.php index 9fab6fca6..d3344f919 100644 --- a/src/Concerns/ManagesTransactions.php +++ b/src/Concerns/ManagesTransactions.php @@ -95,7 +95,7 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [ return; } - // Catch, store and re-throw any exception thrown during execution + // Catch, store, and re-throw any exception thrown during execution // of the callable. The last exception is re-thrown if the transaction // was aborted because the number of callback attempts has been exceeded. try {