-
Notifications
You must be signed in to change notification settings - Fork 28
Eloquent events aware of database transactions. #1441
Comments
Also in this example, an event is dispatched even if a user is not stored yet. /** @test */
public function it_fires_an_eloquent_event_for_committed_transactions()
{
Event::fake();
DB::transaction(function () {
factory(User::class)->create();
sleep(10);
});
Event::assertDispatched("eloquent.created: App\User");
} |
This generally a problem with all the events/listeners/dispatchers: nothing is aware of Transactions. I haven't come around an ActiveRecord system yet which doens't have this inherent problem. My solution so far:
The "abstraction" I mentioned is a custom solution I built for that purpose: https://github.com/mfn/php-transaction-manager Ruby has a similar open issue: rails/rails#26045 The also talk and consider another use-case which isn't "un-important": handling of such events when the Queue system is database based. I personally don't use database-based queuing systems but they definitely have their use-case especially together with transactions. TL;DR: innocent looking but complex topic 😄 |
Well, I disagree about custom events because you can put these outside of a transaction manually, while eloquent is a different story. Also, there might be some events that you want to dispatch even if a transaction is not committed. As a simple solution I think deferring only eloquent events would be fine. |
I guess it's context dependent (not disagreeing though). I think the "perfect" solution would be an issue way to let the developer choose if and what events should honor transactions or not. Then again, maybe this is already the solution I had to implement: custom. |
This is a classic problem from Message Driven systems. I had a few discussions about this at my workplace and the older coworker mentioned that this problem is at least 25 years old. We pinned it down to a very subtle difference on the message sent to the Bus / Topic: A request for change vs A change happened. Pub/Sub systems rely heavily on the 2nd concept; when a message is published, it means something genuinely happened and the world has to deal with that however they see fit. The systems interested in that change will subscribe to the Message / Topic. |
The problem with this approach is sometimes you want the event to be handled inside of the transaction, and sometimes you don't. For example, I might want to add an entry to the On the other hand, if I am using an event to kick off a job I don't want to dispatch the job until the transaction commits. Otherwise the worker could run before the data it needs to use is visible. I've sometimes seen these two types of events called 'domain events' and 'integration events'. The way we solve this at work is by using a marker interface (Similar to It's possible to write an event subscriber to do this. You don't need to change how the Dispatcher works at all. Eloquent already emits transaction began, committed, and rolled back events that you can hook into. For example: class TransactionHoldingSubscriber
{
/**
* @var bool
*/
private $inTransaction = false;
/**
* @var array
*/
private $queued = [];
public function subscribe(Dispatcher $events): void
{
$events->listen(TransactionBeginning::class, function () {
$this->inTransaction = true;
});
$events->listen(TransactionCommitted::class, function () use ($events) {
$this->inTransaction = false;
while ([$event, $payload] = array_pop($this->queued)) {
$events->dispatch($event, $payload);
}
});
$events->listen(TransactionRolledBack::class, function () {
$this->inTransaction = false;
$this->queued = [];
});
$events->listen('*', function (string $event, array $payload) {
if ($this->inTransaction) {
$this->queued[] = [$event, $payload];
return false;
}
});
}
} You can easily expand that example to only hold events matching an interface or pattern, i.e. This could be implemented as a package if someone feels inspired 😁 Note that there is a small risk the process might crash between the DB transaction committing and the events being fired. If that is unacceptable for you you can write events/jobs to the database first. |
Maybe we can take a look how Rails does it: https://guides.rubyonrails.org/active_record_callbacks.html#transaction-callbacks |
Laravel already support Database events by emitting |
Also they are different from what Rails transaction callback does: Rails keeps track of the models mutated in the transaction and fires callbacks on a model basis. Laravel only has the generic callback, void of any models. |
Just a brain dump by using a trait: Trait: trait TransactionalAwareEvents
{
protected static $queuedTransactionalEvents;
public static function bootTransactionalAwareEvents()
{
$events = static::getEventDispatcher();
static::created(function (Model $model) {
if (!DB::transactionLevel()) {
return;
}
self::$queuedTransactionalEvents['created'][] = $model;
});
static::saved(function (Model $model) {
if (!DB::transactionLevel()) {
return;
}
self::$queuedTransactionalEvents['saved'][] = $model;
});
static::updated(function (Model $model) {
if (!DB::transactionLevel()) {
return;
}
self::$queuedTransactionalEvents['updated'][] = $model;
});
static::deleted(function (Model $model) {
if (!DB::transactionLevel()) {
return;
}
self::$queuedTransactionalEvents['deleted'][] = $model;
});
$events->listen(TransactionCommitted::class, function () {
foreach (self::$queuedTransactionalEvents as $eventName => $models) {
foreach ($models as $model) {
$model->fireModelEvent('after_commit_' . $eventName);
}
}
self::$queuedTransactionalEvents = [];
});
$events->listen(TransactionRolledBack::class, function () {
foreach (self::$queuedTransactionalEvents as $eventName => $models) {
foreach ($models as $model) {
$model->fireModelEvent('after_rollback_' . $eventName);
}
}
self::$queuedTransactionalEvents = [];
});
}
public static function afterCommitCreated($callback)
{
static::registerModelEvent('after_commit_created', $callback);
}
public static function afterCommitSaved($callback)
{
static::registerModelEvent('after_commit_saved', $callback);
}
public static function afterCommitUpdated($callback)
{
static::registerModelEvent('after_commit_updated', $callback);
}
public static function afterCommitDeleted($callback)
{
static::registerModelEvent('after_commit_deleted', $callback);
}
public static function afterRollbackCreated($callback)
{
static::registerModelEvent('after_rollback_created', $callback);
}
public static function afterRollbackSaved($callback)
{
static::registerModelEvent('after_rollback_saved', $callback);
}
public static function afterRollbackUpdated($callback)
{
static::registerModelEvent('after_rollback_updated', $callback);
}
public static function afterRollbackDeleted($callback)
{
static::registerModelEvent('after_rollback_deleted', $callback);
}
} Model: class MyModel extends Model
{
use TransactionalAwareEvents;
public static function boot()
{
parent::boot();
static::afterRollbackCreated(function ($model) {
dump("Created MyModel::{$model->id} is rolled back");
});
}
} Tinker:
|
The problem with this solution is that event listeners defined in your trait must always be handled first. I solved my case by simply overriding |
@TitasGailius I understand your case. In this case we add extra events "after_commit_created", "after_commit_updated", etc and keep the working of the regular events in tact (they are performed within the transaction). You get the flexibility to act on events within the transaction (ie "created") and act on events after the transaction is committed (ie "after_commit_created") or rolled back (ie "after_rollback_created". Not that much different as in Rails. As far as I can see the ordering of the listeners doesn't matter. We just record what happened with the model and fire the correct event after the transaction is committed or rolled back. |
I created a package for transactional events https://github.com/mvanduijker/laravel-transactional-model-events This will provide a trait you can put on your models and will fire extra events after commit or rollback. I am going to test it in one of my projects. If there is anyone willing to review / test it, I would really appreciate it. |
@TitasGailius can you share your solution? |
There was a package mentioned for this in #1441 (comment) However I think it falls short because it requires to adapt your models and your code for new events. I believe there might be a better suited package: https://github.com/fntneves/laravel-transactional-events (not mine) It works transparently (if wanted) and, within transactions, prevents events from being immediately fired but does so afterwards. The readme is quite insightful so I'm not going to replicate it here. But my TL;DR is: if Laravel could out of the box provide some "protection" about this problem, that would be real Killer Feature for applications built with it requiring this. |
Personally I don't think that any change is required, as this problem is entirely avoided by having a clear distinction between the use of Eloquent events and custom (business logic) events. We use the post-change Eloquent events (e.g. All business logic events then fire after the transaction. E.g. if you're creating a new user, the Eloquent events aren't massively useful for business logic, because you never know why the model has been stored/changed. E.g. you don't need to send a welcome email to a user being created in the database via a seeder, but you do when they register via the UI/API. That context is important. TLDR: use Eloquent events only for database stuff you want to happen within the transaction, use business logic events outside of the transaction. |
@lindyhopchris Imagine using Laravel Scout. You want to automatically update the index whenever a model is Another example. If you have a I believe there are many cases where the correct behaviour would solve this kind of issues. |
Don't queue the listener. Then it happens within the transaction.
We'd using To go back to the original example: /** @test */
public function it_fires_an_eloquent_event_for_committed_transactions()
{
Event::fake();
DB::beginTransaction();
factory(User::class)->create();
// non-queued listeners execute here, i.e. any database changes they make
// occur within the transaction and get rolled back.
DB::rollBack();
Event::assertDispatched("eloquent.created: App\User");
} Really the original test isn't logically correct, it should be: /** @test */
public function it_fires_an_eloquent_event_for_committed_transactions()
{
Event::fake();
DB::beginTransaction();
factory(User::class)->create();
Event::assertDispatched("eloquent.created: App\User");
DB::rollBack();
} So as an example, if you were creating the user via a registration service that might rollback the user it creates, the following test would be logically correct: Event::fake();
$registrationService->register($dataCausingRollback);
Event::assertDispatched("eloquent.created: App\User"); // fired within transaction.
Event::assertNotDispatched(Registered::class); // event fired after transaction is committed. This isn't the only way of handling this problem, but all I'm saying is we never encounter this problem using the approach I've described. Plus the added benefit is when we're saving models via seeders or factories, we never get a bunch of business logic (non-Eloquent) events being fired. |
You're talking how to overcome this problem however it does not change the fact that when the |
I understand your perspective, but from my perspective this isn't a problem. It's a documentation issue around the explanation of what Eloquent events are and what they should be used for.
Architecturally, to send a welcome email (the example that kicked this off) I need to know that the user registered, not exists in the database. The two are completely different things. The only legit actions to take when Eloquent is doing something in the database is to do other database things - which are therefore covered by the transactions. So my suggestion is this issue is solved by improving the Laravel docs to make it clearer what Eloquent events should be used for. I'd suspect that's the Laravel approach considering there's a That's my perspective. This is an ideas issue, so my input is that I don't think Laravel needs to implement the idea - it just needs to improve its docs around Eloquent events. |
Another example: In a Laravel Nova (from the authors of the Laravel Framework) documentation recommends using events of eloquent models. |
I think it's important that, whether an event is supposed to be transaction safe or not, has to be a developers deliberate decision. I think the arguments brought forward by @lindyhopchris well explain this. In other words:
It would be "rather easy" to do this for eloquent (or any framework-provided) events: you listen for the internal event and dispatch your own, with the marked interface. But no matter if Laravel would natively support this or through a package: whether an event will be queued when transactions are active or not must be opt-in, this is a very important aspect. (sidenote: https://github.com/fntneves/laravel-transactional-events does support this) |
i think this is just something to be aware of when using db transactions, and doesnt really feel like something that could be solved generally (any "fix" is going to break other uses). the only solution thats really coming to mind is having something like the event fake that can be toggled to queue up events rather than firing immediately. so the developer can easily 'pause' and 'resume' event propagation. |
@rs-sliske at least 2 things can be done:
Based on this flag, change the behavior of the framework for non-commited models. |
The model-centric approach is short sighted as you might want non-Eloquent transactional events as well. |
Same problem arises when sending mails within a transaction. I created a package for that as well (https://github.com/mvanduijker/laravel-transactional-mails). It would be nice to have some mechanics to handle these cases within the framework. Especially when you queue stuff you can also get into nasty race condition issues. For example: DB::beginTransaction();
$model->update(['some' => 'stuff']);
Maill::to('[email protected]')->queue(new SomeEmail($model));
// do something that takes time, or the database server is kind a busy
sleep(2);
DB::commit(); The queue will pick up the mail before the database has committed the transaction. If the mail depends on the updated model state things will get nasty. Same thing for queued event listeners, I guess. |
Surely that means That's what I'm struggling to understand with this whole issue! If you're not using the database queue driver, anything that's queued needs to happen after the database transaction has been committed, not before. If you're using the database queue driver, the above example would be fine. But the code isn't very robust because if you swapped to using the Redis queue driver, you'd start getting problems. So your example should be: DB::beginTransaction();
$model->update(['some' => 'stuff']);
DB::commit();
// database now in known state, dispatch to the queue.
Maill::to('[email protected]')->queue(new SomeEmail($model)); Yes some sort of transaction aware event dispatcher would help with this, but only really the developer will know what events/jobs they want within the transaction, and those that they want after the transaction commits. And even if you did add that kind of dispatching, how about events that need to happen between transactions?! Which overall is why I feel this is not a problem of the framework: it's about the developer sequencing things that match the use-case of their application. Which is why IMHO transactional aware event dispatching should be the realm of an optional package, not in the framework. |
"Problem" is a hard word. But there's certainly evidence in this discussion that some kind of support for such cases would be very useful. Usually: the bigger and complex your Laravel application grows, the more this becomes an issue. |
I feel like I should post this again. The context of an Eloquent event is subjective to change. Eloquent events should not be transaction aware and you should use your own event to represent your domain context instead. |
FTR, this is exactly what the package I mentioned in #1441 (comment) provides |
Well, this discussion kinda went off-topic because initially, I was talking only about Eloquent events. In my honest opinion, it makes sense to dispatch Either way, if a project really needs to support this kind of feature, it is a small and easy change to add so probably it's not worth introducing this kind of a breaking change to the core. |
Hello everyone, based on an existing package and with permission of its original author, I've drafted a possible solution to tackle the "transactional events" problem. At the moment it's a draft PR in my Laravel forked repo to first gather feedback from the community before approaching to create an official PR, to give things time to unfold / develop. I invite everyone to participate in mfn/laravel-framework#1 and share your feedback. Thank you! |
I has a situation where a database transaction fails but an eloquent event is still being dispatched.
To illustrate this problem take this test into consideration:
Everything works as expected. The user gets stored into the database and an event is fired.
Although if we manually fail the transaction, an event would still be fired:
Problem:
The user is not stored into the database but an event is still being fired.
This creates situations where a specific action would be done even if they shouldn't. "Welcome email" is a perfect example, it would try to send the email even if the user is not there.
Suggestion:
Collect pending eloquent events and dispatch them only after the transaction is committed.
The text was updated successfully, but these errors were encountered: