Skip to content

Commit dabfabd

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 dabfabd

File tree

2 files changed

+291
-3
lines changed

2 files changed

+291
-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: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
<?php
2+
3+
namespace MongoDB\Laravel\Tests\Ticket;
4+
5+
use Closure;
6+
use Exception;
7+
use Illuminate\Contracts\Database\ConcurrencyErrorDetector;
8+
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
9+
use Illuminate\Database\Connection;
10+
use Illuminate\Database\Events\TransactionBeginning;
11+
use Illuminate\Database\Events\TransactionCommitted;
12+
use Illuminate\Database\Events\TransactionCommitting;
13+
use Illuminate\Database\Events\TransactionRolledBack;
14+
use Illuminate\Foundation\Events\Dispatchable;
15+
use Illuminate\Support\Facades\DB;
16+
use Illuminate\Support\Facades\Event;
17+
use MongoDB\Driver\Exception\RuntimeException;
18+
use MongoDB\Laravel\Tests\TestCase;
19+
use Throwable;
20+
21+
use function event;
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+
$this->app->bind(ConcurrencyErrorDetector::class, FakeConcurrencyErrorDetector::class);
52+
53+
$callback = static function (): void {
54+
event(new RegularEvent());
55+
event(new AfterCommitEvent());
56+
57+
// Transaction failed; after commit event should not be dispatched
58+
throw new FakeException();
59+
};
60+
61+
$assert = function (): void {
62+
Event::assertDispatchedTimes(BeforeTransactionEvent::class, 3);
63+
Event::assertDispatchedTimes(RegularEvent::class, 3);
64+
65+
Event::assertDispatchedTimes(TransactionBeginning::class, 3);
66+
Event::assertDispatched(TransactionRolledBack::class);
67+
Event::assertNotDispatched(TransactionCommitting::class);
68+
Event::assertNotDispatched(TransactionCommitted::class);
69+
};
70+
71+
$this->assertTransactionCallbackResult($callback, $assert, 3);
72+
}
73+
74+
public function testAfterCommitOnSuccessfulManualTransaction(): void
75+
{
76+
$callback = function (): void {
77+
event(new RegularEvent());
78+
event(new AfterCommitEvent());
79+
};
80+
81+
$assert = function (): void {
82+
Event::assertDispatchedTimes(BeforeTransactionEvent::class);
83+
Event::assertDispatchedTimes(RegularEvent::class);
84+
Event::assertDispatchedTimes(AfterCommitEvent::class);
85+
86+
Event::assertDispatched(TransactionBeginning::class);
87+
Event::assertNotDispatched(TransactionRolledBack::class);
88+
Event::assertDispatched(TransactionCommitting::class);
89+
Event::assertDispatched(TransactionCommitted::class);
90+
};
91+
92+
$this->assertTransactionResult($callback, $assert);
93+
}
94+
95+
public function testAfterCommitOnFailedManualTransaction(): void
96+
{
97+
$callback = function (): void {
98+
event(new RegularEvent());
99+
event(new AfterCommitEvent());
100+
101+
throw new FakeException();
102+
};
103+
104+
$assert = function (): void {
105+
Event::assertDispatchedTimes(BeforeTransactionEvent::class);
106+
Event::assertDispatchedTimes(RegularEvent::class);
107+
Event::assertNotDispatched(AfterCommitEvent::class);
108+
109+
Event::assertDispatched(TransactionBeginning::class);
110+
Event::assertDispatched(TransactionRolledBack::class);
111+
Event::assertNotDispatched(TransactionCommitting::class);
112+
Event::assertNotDispatched(TransactionCommitted::class);
113+
};
114+
115+
$this->assertTransactionResult($callback, $assert);
116+
}
117+
118+
private function assertTransactionCallbackResult(Closure $callback, Closure $assert, ?int $attempts = 1): void
119+
{
120+
$this->assertCallbackResultForConnection(
121+
DB::connection('sqlite'),
122+
$callback,
123+
$assert,
124+
$attempts,
125+
);
126+
127+
$this->assertCallbackResultForConnection(
128+
DB::connection('mongodb'),
129+
$callback,
130+
$assert,
131+
$attempts,
132+
);
133+
}
134+
135+
/**
136+
* Ensure equal transaction behavior between SQLite (handled by Laravel) and MongoDB
137+
*/
138+
private function assertCallbackResultForConnection(Connection $connection, Closure $callback, Closure $assertions, int $attempts): void
139+
{
140+
$fake = Event::fake();
141+
$connection->setEventDispatcher($fake);
142+
$connection->beforeStartingTransaction(function () {
143+
event(new BeforeTransactionEvent());
144+
});
145+
146+
try {
147+
$connection->transaction($callback, $attempts);
148+
} catch (Exception) {
149+
}
150+
151+
$assertions();
152+
}
153+
154+
private function assertTransactionResult(Closure $callback, Closure $assert): void
155+
{
156+
$this->assertManualResultForConnection(
157+
DB::connection('sqlite'),
158+
$callback,
159+
$assert,
160+
);
161+
162+
$this->assertManualResultForConnection(
163+
DB::connection('mongodb'),
164+
$callback,
165+
$assert,
166+
);
167+
}
168+
169+
/**
170+
* Ensure equal transaction behavior between SQLite (handled by Laravel) and MongoDB
171+
*/
172+
private function assertManualResultForConnection(Connection $connection, Closure $callback, Closure $assert): void
173+
{
174+
$fake = Event::fake();
175+
$connection->setEventDispatcher($fake);
176+
177+
$connection->beforeStartingTransaction(function () {
178+
event(new BeforeTransactionEvent());
179+
});
180+
181+
$connection->beginTransaction();
182+
183+
try {
184+
$callback();
185+
$connection->commit();
186+
} catch (Exception) {
187+
$connection->rollBack();
188+
}
189+
190+
$assert();
191+
}
192+
}
193+
194+
class AfterCommitEvent implements ShouldDispatchAfterCommit
195+
{
196+
use Dispatchable;
197+
}
198+
199+
class BeforeTransactionEvent
200+
{
201+
use Dispatchable;
202+
}
203+
class RegularEvent
204+
{
205+
use Dispatchable;
206+
}
207+
class FakeException extends RuntimeException
208+
{
209+
public function __construct()
210+
{
211+
$this->errorLabels = ['TransientTransactionError'];
212+
}
213+
}
214+
215+
class FakeConcurrencyErrorDetector implements ConcurrencyErrorDetector
216+
{
217+
public function causedByConcurrencyError(Throwable $e): bool
218+
{
219+
return true;
220+
}
221+
}

0 commit comments

Comments
 (0)