Skip to content

Commit 1c17a2c

Browse files
committed
Use DB tx manager in ManagesTransactions trait
`DatabaseTransactionsManager` was introduced in Laravel to keep track of things like pending transactions and fire transaction events, so that e.g. dispatches could hook into transaction state. Our trait was not using it yet, resulting in missing `afterCommit` behavior (see PHPLIB-373)
1 parent c483b99 commit 1c17a2c

File tree

2 files changed

+313
-3
lines changed

2 files changed

+313
-3
lines changed

src/Concerns/ManagesTransactions.php

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use MongoDB\Driver\Session;
1111
use Throwable;
1212

13+
use function max;
1314
use function MongoDB\with_transaction;
1415

1516
/**
@@ -55,32 +56,90 @@ private function getSessionOrThrow(): Session
5556
*/
5657
public function beginTransaction(array $options = []): void
5758
{
59+
$this->runCallbacksBeforeTransaction();
60+
5861
$this->getSessionOrCreate()->startTransaction($options);
62+
63+
$this->handleInitialTransactionState();
64+
}
65+
66+
private function handleInitialTransactionState(): void
67+
{
5968
$this->transactions = 1;
69+
70+
$this->transactionsManager?->begin(
71+
$this->getName(),
72+
$this->transactions,
73+
);
74+
75+
$this->fireConnectionEvent('beganTransaction');
6076
}
6177

6278
/**
6379
* Commit transaction in this session.
6480
*/
6581
public function commit(): void
6682
{
83+
$this->fireConnectionEvent('committing');
6784
$this->getSessionOrThrow()->commitTransaction();
68-
$this->transactions = 0;
85+
86+
$this->handleCommitState();
87+
}
88+
89+
private function handleCommitState(): void
90+
{
91+
[$levelBeingCommitted, $this->transactions] = [
92+
$this->transactions,
93+
max(0, $this->transactions - 1),
94+
];
95+
96+
$this->transactionsManager?->commit(
97+
$this->getName(),
98+
$levelBeingCommitted,
99+
$this->transactions,
100+
);
101+
102+
$this->fireConnectionEvent('committed');
69103
}
70104

71105
/**
72106
* Abort transaction in this session.
73107
*/
74108
public function rollBack($toLevel = null): void
75109
{
76-
$this->getSessionOrThrow()->abortTransaction();
110+
$session = $this->getSessionOrThrow();
111+
if ($session->isInTransaction()) {
112+
$session->abortTransaction();
113+
}
114+
115+
$this->handleRollbackState();
116+
}
117+
118+
private function handleRollbackState(): void
119+
{
77120
$this->transactions = 0;
121+
122+
$this->transactionsManager?->rollback(
123+
$this->getName(),
124+
$this->transactions,
125+
);
126+
127+
$this->fireConnectionEvent('rollingBack');
128+
}
129+
130+
private function runCallbacksBeforeTransaction(): void
131+
{
132+
foreach ($this->beforeStartingTransaction as $beforeTransactionCallback) {
133+
$beforeTransactionCallback($this);
134+
}
78135
}
79136

80137
/**
81138
* Static transaction function realize the with_transaction functionality provided by MongoDB.
82139
*
83-
* @param int $attempts
140+
* @param int $attempts
141+
*
142+
* @throws Throwable
84143
*/
85144
public function transaction(Closure $callback, $attempts = 1, array $options = []): mixed
86145
{
@@ -93,15 +152,20 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [
93152

94153
if ($attemptsLeft < 0) {
95154
$session->abortTransaction();
155+
$this->handleRollbackState();
96156

97157
return;
98158
}
99159

160+
$this->runCallbacksBeforeTransaction();
161+
$this->handleInitialTransactionState();
162+
100163
// Catch, store, and re-throw any exception thrown during execution
101164
// of the callable. The last exception is re-thrown if the transaction
102165
// was aborted because the number of callback attempts has been exceeded.
103166
try {
104167
$callbackResult = $callback($this);
168+
$this->fireConnectionEvent('committing');
105169
} catch (Throwable $throwable) {
106170
throw $throwable;
107171
}
@@ -110,9 +174,12 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [
110174
with_transaction($this->getSessionOrCreate(), $callbackFunction, $options);
111175

112176
if ($attemptsLeft < 0 && $throwable) {
177+
$this->handleRollbackState();
113178
throw $throwable;
114179
}
115180

181+
$this->handleCommitState();
182+
116183
return $callbackResult;
117184
}
118185
}

tests/Ticket/GH3328Test.php

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
<?php
2+
3+
namespace MongoDB\Laravel\Tests\Ticket;
4+
5+
use Closure;
6+
use Exception;
7+
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
8+
use Illuminate\Database\Connection;
9+
use Illuminate\Database\Events\TransactionBeginning;
10+
use Illuminate\Database\Events\TransactionCommitted;
11+
use Illuminate\Database\Events\TransactionCommitting;
12+
use Illuminate\Database\Events\TransactionRolledBack;
13+
use Illuminate\Foundation\Events\Dispatchable;
14+
use Illuminate\Support\Facades\DB;
15+
use Illuminate\Support\Facades\Event;
16+
use MongoDB\Driver\Exception\RuntimeException;
17+
use MongoDB\Laravel\Tests\TestCase;
18+
use Throwable;
19+
20+
use function event;
21+
use function interface_exists;
22+
23+
/**
24+
* @see https://github.com/mongodb/laravel-mongodb/issues/3328
25+
* @see https://jira.mongodb.org/browse/PHPORM-373
26+
*/
27+
class GH3328Test extends TestCase
28+
{
29+
public function testAfterCommitOnSuccessfulTransaction(): void
30+
{
31+
$callback = static function (): void {
32+
event(new RegularEvent());
33+
event(new AfterCommitEvent());
34+
};
35+
36+
$assert = function (): void {
37+
Event::assertDispatchedTimes(BeforeTransactionEvent::class);
38+
Event::assertDispatchedTimes(RegularEvent::class);
39+
Event::assertDispatchedTimes(AfterCommitEvent::class);
40+
41+
Event::assertDispatched(TransactionBeginning::class);
42+
Event::assertDispatched(TransactionCommitting::class);
43+
Event::assertDispatched(TransactionCommitted::class);
44+
};
45+
46+
$this->assertTransactionCallbackResult($callback, $assert);
47+
}
48+
49+
public function testAfterCommitOnFailedTransaction(): void
50+
{
51+
$callback = static function (): void {
52+
event(new RegularEvent());
53+
event(new AfterCommitEvent());
54+
55+
// Transaction failed; after commit event should not be dispatched
56+
throw new Fake();
57+
};
58+
59+
$assert = function (): void {
60+
Event::assertDispatchedTimes(BeforeTransactionEvent::class, 3);
61+
Event::assertDispatchedTimes(RegularEvent::class, 3);
62+
63+
Event::assertDispatchedTimes(TransactionBeginning::class, 3);
64+
Event::assertDispatched(TransactionRolledBack::class);
65+
Event::assertNotDispatched(TransactionCommitting::class);
66+
Event::assertNotDispatched(TransactionCommitted::class);
67+
};
68+
69+
$this->assertCallbackResultForConnection(
70+
DB::connection('mongodb'),
71+
$callback,
72+
$assert,
73+
3,
74+
);
75+
76+
if (! interface_exists('\Illuminate\Contracts\Database\ConcurrencyErrorDetector')) {
77+
// Earlier versions of Laravel use a trait instead of DI to detect concurrency errors
78+
// That would increase the scope of this comparison dramatically and is probably not worth it.
79+
return;
80+
}
81+
82+
// phpcs:ignore
83+
$this->app->bind(\Illuminate\Contracts\Database\ConcurrencyErrorDetector::class, FakeConcurrencyErrorDetector::class);
84+
85+
$this->assertCallbackResultForConnection(
86+
DB::connection('sqlite'),
87+
$callback,
88+
$assert,
89+
3,
90+
);
91+
}
92+
93+
public function testAfterCommitOnSuccessfulManualTransaction(): void
94+
{
95+
$callback = function (): void {
96+
event(new RegularEvent());
97+
event(new AfterCommitEvent());
98+
};
99+
100+
$assert = function (): void {
101+
Event::assertDispatchedTimes(BeforeTransactionEvent::class);
102+
Event::assertDispatchedTimes(RegularEvent::class);
103+
Event::assertDispatchedTimes(AfterCommitEvent::class);
104+
105+
Event::assertDispatched(TransactionBeginning::class);
106+
Event::assertNotDispatched(TransactionRolledBack::class);
107+
Event::assertDispatched(TransactionCommitting::class);
108+
Event::assertDispatched(TransactionCommitted::class);
109+
};
110+
111+
$this->assertTransactionResult($callback, $assert);
112+
}
113+
114+
public function testAfterCommitOnFailedManualTransaction(): void
115+
{
116+
$callback = function (): void {
117+
event(new RegularEvent());
118+
event(new AfterCommitEvent());
119+
120+
throw new Fake();
121+
};
122+
123+
$assert = function (): void {
124+
Event::assertDispatchedTimes(BeforeTransactionEvent::class);
125+
Event::assertDispatchedTimes(RegularEvent::class);
126+
Event::assertNotDispatched(AfterCommitEvent::class);
127+
128+
Event::assertDispatched(TransactionBeginning::class);
129+
Event::assertDispatched(TransactionRolledBack::class);
130+
Event::assertNotDispatched(TransactionCommitting::class);
131+
Event::assertNotDispatched(TransactionCommitted::class);
132+
};
133+
134+
$this->assertTransactionResult($callback, $assert);
135+
}
136+
137+
private function assertTransactionCallbackResult(Closure $callback, Closure $assert, ?int $attempts = 1): void
138+
{
139+
$this->assertCallbackResultForConnection(
140+
DB::connection('sqlite'),
141+
$callback,
142+
$assert,
143+
$attempts,
144+
);
145+
146+
$this->assertCallbackResultForConnection(
147+
DB::connection('mongodb'),
148+
$callback,
149+
$assert,
150+
$attempts,
151+
);
152+
}
153+
154+
/**
155+
* Ensure equal transaction behavior between SQLite (handled by Laravel) and MongoDB
156+
*/
157+
private function assertCallbackResultForConnection(Connection $connection, Closure $callback, Closure $assertions, int $attempts): void
158+
{
159+
$fake = Event::fake();
160+
$connection->setEventDispatcher($fake);
161+
$connection->beforeStartingTransaction(function () {
162+
event(new BeforeTransactionEvent());
163+
});
164+
165+
try {
166+
$connection->transaction($callback, $attempts);
167+
} catch (Exception) {
168+
}
169+
170+
$assertions();
171+
}
172+
173+
private function assertTransactionResult(Closure $callback, Closure $assert): void
174+
{
175+
$this->assertManualResultForConnection(
176+
DB::connection('sqlite'),
177+
$callback,
178+
$assert,
179+
);
180+
181+
$this->assertManualResultForConnection(
182+
DB::connection('mongodb'),
183+
$callback,
184+
$assert,
185+
);
186+
}
187+
188+
/**
189+
* Ensure equal transaction behavior between SQLite (handled by Laravel) and MongoDB
190+
*/
191+
private function assertManualResultForConnection(Connection $connection, Closure $callback, Closure $assert): void
192+
{
193+
$fake = Event::fake();
194+
$connection->setEventDispatcher($fake);
195+
196+
$connection->beforeStartingTransaction(function () {
197+
event(new BeforeTransactionEvent());
198+
});
199+
200+
$connection->beginTransaction();
201+
202+
try {
203+
$callback();
204+
$connection->commit();
205+
} catch (Exception) {
206+
$connection->rollBack();
207+
}
208+
209+
$assert();
210+
}
211+
}
212+
213+
class AfterCommitEvent implements ShouldDispatchAfterCommit
214+
{
215+
use Dispatchable;
216+
}
217+
218+
class BeforeTransactionEvent
219+
{
220+
use Dispatchable;
221+
}
222+
class RegularEvent
223+
{
224+
use Dispatchable;
225+
}
226+
class Fake extends RuntimeException
227+
{
228+
public function __construct()
229+
{
230+
$this->errorLabels = ['TransientTransactionError'];
231+
}
232+
}
233+
234+
if (interface_exists('\Illuminate\Contracts\Database\ConcurrencyErrorDetector')) {
235+
// phpcs:ignore
236+
class FakeConcurrencyErrorDetector implements \Illuminate\Contracts\Database\ConcurrencyErrorDetector
237+
{
238+
public function causedByConcurrencyError(Throwable $e): bool
239+
{
240+
return true;
241+
}
242+
}
243+
}

0 commit comments

Comments
 (0)