Record the queue exception caused by Laravel Observer

1. Business logic

After a new model is Created, use the Observer model event Created to push the asynchronous short message sending queue

App\Http\Controllers\UsersController

    public function store(User $user)
    {
        \DB::beginTransaction();

        try{
            $input = request()->validated();
            $user->fill($input);
            $user->save();
            //do something......
            //Other data table operations

            \DB::commit();
        } catch ($e \Exception) {
            \DB::rollBack();
        }

    }

App\Observers\UsersObserver

class UsersObserver
{
    public function created (User $user)
    {
        dispatch(new SmsQueue($user));
    }
}

2. Exception found

According to the feedback of the business department, occasionally there are users who can't receive SMS notifications. I check the log and find occasional errors and exceptions: No query results for model [App\Models\User]. It means that the corresponding model can't be found

I don't think so. I call the queue after creating the model... So I carefully check the business code and guess that it should be affected by the transaction.

Verify conjecture:

    public function store(User $user)
    {
        \DB::beginTransaction();

        try{
            $input = request()->validated();
            $user->fill($input);
            $user->save();
            //do something......
            //Other data table operations

            sleep(3); //Commit the transaction after three seconds            
            \DB::commit();
        } catch ($e \Exception) {
            \DB::rollBack();
        }

    }

Sure enough, after waiting for three seconds, the submission queue exception is 100% triggered.

3. Cause analysis

  1. If the $user - > save() method successfully creates data, it will trigger the scheduler to execute the model events one by one.
  2. In the event, the model is pushed to the queue, and the queue process consumes data in the queue continuously.
  3. In most cases, if the processing speed of do something is normal, the queue process will run as usual.
  4. If there is occasional delay in the do something phase, the transaction has not been commit ted and the queue has begun to consume the new model; Therefore, the above error is caused.

Then, when I searched Github Issues records, I found that this problem occurred in a year in 2015 Issue It has been proposed, and the support for transaction model events is finally added in Laravel 8.X; Document address , there seems to be no explanation in the community documentation~

Because my version is 6.x, I can't use this new feature [crying]~~

4. Resolve exception

1. Modify MySQL transaction isolation level (not recommended)

This involves the transaction isolation level of MySQL. The default isolation level of InnoDB engine is REPEATABLE READ. The differences between various levels can be found in Official documents Found.

This problem can be solved by switching the isolation level to READ UNCOMMITTED, but in order to prevent bigger problems, I advise you not to use this method~

2. Add event listening

Check the source code and know that the corresponding event will be called after the transaction is completed, so you only need to increase the event listening.

  1. New class App\Handlers\TransactionHandler

    class TransactionHandler
    {
        public array $handlers;
    
        public function __construct()
        {
            $this->handlers = [];
        }
    
        public function add(\Closure $handler)
        {
            $this->handlers[] = $handler;
        }
    
        public function run()
        {
            foreach ($this->handlers as $handler) {
                $handler();
            }
        }
    }
  2. Create helper function app/helpers.php

    if (! function_exists('after_transaction')) {
        /*
         * Do not operate until the transaction is completed
         * */
        function after_transaction(Closure $job)
        {
            app()->singletonIf(\App\Handlers\TransactionHandler::class, function (){
                return new \App\Handlers\TransactionHandler();
            });
            app(\App\Handlers\TransactionHandler::class)->add($job);
        }
    }
    
  3. Create listening App\Listeners\TransactionListener

    namespace App\Listeners;
    
    use App\Handlers\TransactionHandler;
    
    class TransactionListener
    {
        public function handle()
        {
            app(TransactionHandler::class)->run();
        }
    }
  4. Bind listening App\Providers\EventServiceProvider

    namespace App\Providers;
    
    use App\Listeners\TransactionListener;
    use Illuminate\Database\Events\TransactionCommitted;
    use Illuminate\Database\Events\TransactionRolledBack;
    use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;;
    
    class EventServiceProvider extends ServiceProvider
    {
        /**
         * The event listener mappings for the application.
         *
         * @var array
         */
        protected $listen = [
            TransactionCommitted::class => [
                TransactionListener::class
            ],
            TransactionRolledBack::class => [
                TransactionListener::class
            ]
        ];
    }
    
  5. Change the calling method App\Observers\UsersObserver

class UsersObserver
{
    public function created (User $user)
    {
        after_transaction(function() use ($user) {
            dispatch(new SmsQueue($user));
        });
    }
}

OK, an elegant solution is completed~~

Keywords: Laravel

Added by Tensing on Fri, 29 Oct 2021 10:37:04 +0300