Grayscale profile picture

Patrique Ouimet

Developer

Laravel: Don't Queue Jobs Inside of a Transaction

Sat, Aug 11, 2018 1:07 PM

Introduction

This post hopes to explain a mistake I made recently while using a transaction and Laravel's model events with a job. Likely you read that sentence and thought "why would anyone ever do that?" Well, in this short post I'll explain how I got to that point and then what was done to remedy the situation/bug.

Back Story

A few years ago (2016?) my company (The Mobile Experience Company) decided to move individual offerings to a centralized application/platform. In the middle of doing this we were still delivering against client business needs. Needless to say we had to get it done quickly. Armed with my little knowledge of Laravel I attempted (along with the other developer) to put this together as quickly as possible, after a bit we had our first working version up and "running". It consisted of a simple event in, action out kind of setup which was triggered through one API call.

Fast forward a few years, we added in plenty of features to that little end point and it has quickly devolved into something unrecognizable.

The New Problem

As it gained popularity and we got more and more traffic we started getting deadlock (record or table being locked by another process) issues with the database as we used the database queue driver. The simple solution was to change the queue driver, so we switched to redis. Afterwards we were back to normal or so we thought. The deadlock issue showed us another problem with how we managed communications through the API.

Data Sync Between Two Services

When the deadlocks occurred we had a second problem. Our infrastructure is setup as one main platform then a bunch of micro services which communicate back and forth. Here's a quick run down of the second problem:

  1. Micro service X creates a record in micro_service_x.table_1
  2. Micro service makes an API call to the platform
  3. Platform has a deadlock exception, no record is added to platform.table_1

Now we've got things out of whack as we need both services to have the same record count (we recognize this is a poor design and are moving everything to the service).

Straight away we jumped for the easy solution, wrap all the communications within transactions on both sides. So if the API call at step #2 fails, the micro service will rollback anything within the transaction. Sounds like a good plan right? It is, but it caused another problem.

Fixed one, made another

For this one we're going to need some sample code.

// Model
class MyModel extends Model
{
    public function boot ()
    {
        static::created(function ($model) {
            event(new MyEvent($model));
        });
    }
}

// Within API Controller method
DB::transaction (function () {
    app(MyModel::class)->create(['name' => 'My Cool Model']);
});

This code seems completely harmless, and without transactions it works. Now, when you introduce a transaction the model gets created and fires off the model event BUT it does not have an id yet. This proves to be a problem as the queuing system uses the id to find and serialize/deserialize the model and since there's no id it throws an exception, something similar to (stripped down just to see the important bits):

Illuminate\Database\Eloquent\ModelNotFoundException: No query results for model [App\MyModel]
vendor/laravel/framework/src/Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php(46)

This is weird to debug at first, you'll check the database and the model is there. You'll re-run the job and it'll work no problem. This is because the job (or event fire) fails within the transaction, then the transaction commits and creates the model once the job has already failed. This comes down to an order of operations problem.

The Solution

The solution is actually quite obvious once you understand the problem.

  1. Wait for the transaction to finish
  2. Run the job after the transaction
// Model
class MyModel extends Model
{
    // Removed event dispatcher
}

// Within API Controller method
$model = DB::transaction (function () {
    return app(MyModel::class)->create(['name' => 'My Cool Model']);
});

if ($model) {
     event(new MyEvent($model));
}

Conclusion

This was a headache to figure out at first (though it should have been obvious), once I understood it the solution was quick and easy to implement.

Hope you enjoyed reading this article! Share it if you like!

Comments

Feb 28, 2022

wtmizilb38

Aug 1, 2023

Encountered same problem today! I've suspected it was because of wrong usage of jobs, thanks for confirming!