This article is for those who struggle with Doctrine to make their business logic thread-safe. I suppose you have sufficient knowledge in MySQL transactions and locks in conjunction with InnoDB tables. Other RDBMS configurations might work as well but I did not try any of them related to what’s following in this article.
Doctrine provides abstraction layer for SQL transactions and locks but the documentation is incomplete. Yet, if you search for Transactions and Concurrency in the official documentation, you will find the first bricks to reach our goal.
The Doctrine EntityManager can manage a transaction with methods which behave exactly as their names imply: beginTransaction(), commit(), and rollback(). I won’t enter in detail as I consider you are familiar with these terms.
A very small paragraph in the official documentation explains how you can append LOCK IN SHARE MODE and FOR UPDATE locks to your SQL queries by passing constants LockMode::PESSIMISTIC_READ and LockMode::PESSIMISTIC_WRITE, respectively, to Doctrine Query instances.
Thus far, everything seems simple and works as we would expect. But here the official documentation starts to blur. We still have to manage the exceptions of the dead locks and lock timeouts, but there is not a single word mentioned on this subject. Here’s what I discovered, getting my hands dirty.
Dead locks’ and lock timeouts’ MySQL exceptions are caught and encapsulated in Doctrine RetryableException. But catching this exception is not the whole lot yet, because Doctrine EntityManager becomes totally unusable when a Doctrine exception has been raised.
The official documentation says, in a confusing manner, that we should reset the EntityManager after such an Exception has been raised. What it doesn’t say clearly is that it does not really reset the EntityManager; but rather it builds and provides a new one!
Because of this fact, you must not continue to use the old EntityManager. But the problem is that we have the habit to inject the EntityManager everywhere where it’s needed in our code, therefore keeping obsolete and dangerous references to it. After the EntityManager is reset, you must redistribute the new EntityManager! Beware of some vendor classes that keeps old references as well. For example, all Doctrine repository instances have a reference to an EntityManager which will not be updated on reset.
The solution I came with is a composed EntityManager object. It does not inherit the EntityManager, but it contains one. It provides the same interface as the Doctrine EntityManager (via magic method __call()), plus two very useful custom methods. This custom EntityManager should be injected everywhere in place of the Doctrine EntityManager. Thus you won’t need to redistribute it in case of Doctrine exception. Only the internal reference will change.
The first custom method, transactional(), handles all the transaction logic for you. It takes a single callable parameter which should contain the business logic you want to execute thread-safely. The only thing you’re still responsible of is to put locks wherever it’s necessary in the Doctrine queries of your business logic.
The second custom method simply resets the EntityManager. Once again, do not use the old reference and beware of repositories like in example below:
$rep = $em->getRepository(MyClass::class);
$rep will keep behind the scenes an obsolete reference to the EntityManager. The repository will have to be re-instantiated with the fresh EntityManager.
No more suspense, here is the custom EntityManager: