DEV Community

Olivier Lechevalier
Olivier Lechevalier

Posted on

Doctrine: HOW TO recover from rolled back transaction

We all wish we would have to deal only with the happy paths, sadly in reality errors happen and we have to handle them.

One way to clean the mess after an error happened is database transactions and particularly rollbacks. Transaction is a powerful tool to prevent our application to leave the database in an unknown or broken state.

Sadly most of ORMs are not really well equipped to deal with rollbacks.

Let's take a look at a small real world example. Our application needs to send some email to customers and record that the email was sent. The processing is done in batch by doing the action on one customer at a time.


public function sendWeeklyNewsletter(): void
{
    $customers = $repository->findAll();

    foreach ($customers as $customer) {
        try {
            $entityManager->transactional(
                function () use ($customer) {
                    $this->sendWeeklyNewsletter($customer);
                    $this->recordEmailNotification($customer);
                }
            );
        } catch (EmailNotTransmittedException $e) {

        }
    }
}

Let's say the first email could not be sent (random outages, incorrect email, etc), you would think it should work, transactional would rollback the transaction and it would continue processing the next customer.

Unfortunately, it's not the case, you would be rewarded with this exception PHP Fatal error: Uncaught Doctrine\ORM\ORMException: The EntityManager is closed..

Taking a look at the code of EntityManager::transactional would explain why.

    /**
     * {@inheritDoc}
     */
    public function transactional($func)
    {
        if (!is_callable($func)) {
            throw new \InvalidArgumentException('Expected argument of type "callable", got "' . gettype($func) . '"');
        }

        $this->conn->beginTransaction();

        try {
            $return = call_user_func($func, $this);

            $this->flush();
            $this->conn->commit();

            return $return ?: true;
        } catch (Throwable $e) {
            $this->close();
            $this->conn->rollBack();

            throw $e;
        }
    }

Basically because dealing with rollback is complicated. The doctrine maintainers choose to close the entity manager as soon a rollback happens. The documentation includes such warning:

As a result of this procedure, all previously managed or removed instances of the EntityManager become detached. The state of the detached objects will be the state at the point at which the transaction was rolled back. The state of the objects is in no way rolled back and thus the objects are now out of synch with the database. The application can continue to use the detached objects, knowing that their state is potentially no longer accurate.
If you intend to start another unit of work after an exception has occurred you should do that with a new EntityManager.

TD;LR you can't use an entity manager after a rollback happened.

Now how do we do our batch processing if we can't recover from errors!?!?

oh dear jesus

We need to create a new entity manager every time a rollback happens. This could get complicated if you are using a container because it would share the same instance of entity manager everywhere.

Behold the AbstractManagerRegistry, this registry has a resetManager method that allow to create a new entity manager.

Anyway if you are using symfony framework, the doctrine bridge bundle provides an implementation of the doctrine registry and allow you to reset the entity manager. I am pretty sure other frameworks integration should provide the same feature.

So our code becomes:


public function sendWeeklyNewsletter(): void
{
    $customers = $repository->findAll();

    foreach ($customers as $customer) {
        try {
            $entityManager->transactional(
                function () use ($customer) {
                    $this->sendWeeklyNewsletter($customer);
                    $this->recordEmailNotification($customer);
                }
            );
        } catch (EmailNotTransmittedException $e) {
            $this->registry->resetManager();
        }
    }
}

The attentive reader might have caught a problem in our logic. The entity manager was already passed to our class so it can't be swapped. In fact the symfony doctrine bundle rely on the wonderful proxy manager library written by Ocramius to wrap the EntityManager object so it can be swapped dynamically.

smart

Anyway you can take a look at the implementation of the registry.

Happy batch processing!

Top comments (0)