It seems I have two Doctrine Entity Managers but I

2019-09-06 19:55发布

问题:

I've spent last days solving a very subtle bug in my bundle.

Practically I get a Job entity from the database. This entity has a one-to-one, self-referencing relation to another Job entity on the property retryOf.

When I try to update the retrieved Job#status property, I get the following exception thrown:

[Doctrine\ORM\ORMInvalidArgumentException]
A new entity was found through the relationship 'SerendipityHQ\Bundle\CommandsQueuesBundle\Entity\Job#retryOf' that was not configured to cascade persist operations for entity: SerendipityHQ\Bundle\CommandsQueuesBundle\Entity\Job@000000004ab2727c0000000065 d15d74. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}). If you cannot find out which entity causes the problem implement 'SerendipityHQ\Bundle\CommandsQueuesBundle\Entity\Job#__toString()' to get a clue.

In the end I discovered that the EntityManager I use to retrieve and persist/flush entities is different from the one used by my entities to manage themselves and retrieve related entities.

So, my question is: assuming I'm injecting the right EntityManager in my services (@doctrine.orm.default_entity_manager):

  1. Why my services use one and the repositories use another?
  2. How can I make my entities use the same I inject in services? (or how can I inject the same used by my entities - is the same)?

HOW I INJECT THE EntityManager

services:
    queues:
        class: SerendipityHQ\Bundle\CommandsQueuesBundle\Service\QueuesManager
        arguments: ["@commands_queues.do_not_use.entity_manager"]
    #This service is meant to be privately used by QueuesRunCommand
    commands_queues.do_not_use.daemon:
        class: SerendipityHQ\Bundle\CommandsQueuesBundle\Service\QueuesDaemon
        arguments: ["@commands_queues.do_not_use.entity_manager"]

The EntityManager is created in the SHQCommandsQueuesExtension (see its code on GitHub).

The problem is really hard, as I receive the error but I cannot persist again in the entity manager used by services: this cause a duplication of rows in the database!

HOW I SPECIFY THE JobRepository CLASS

/**
 * Basic properties and methods o a Job.
 *
 * @ORM\Entity(repositoryClass="SerendipityHQ\Bundle\CommandsQueuesBundle\Repository\JobRepository")
 * @ORM\Table(name="queues_scheduled_jobs")
 */
class Job
{
...
}

/**
 * {@inheritdoc}
 */
class JobRepository extends EntityRepository
{
    /**
     * @param int $id
     *
     * @return null|object|Job
     */
    public function findOneById(int $id)
    {
        return parent::findOneBy(['id' => $id]);
    }

    /**
     * Returns a Job that can be run.
     *
     * A Job can be run if it hasn't a startDate in the future and if its parent Jobs are already terminated with
     * success.
     *
     * @return null|Job
     */
    public function findNextRunnableJob()
    {
        // Collects the Jobs that have to be excluded from the next findNextJob() call
        $excludedJobs = [];

        while (null !== $job = $this->findNextJob($excludedJobs)) {
            // If it can be run...
            if (false === $job->hasNotFinishedParentJobs()) {
                // ... Return it
                return $job;
            }

            // The Job cannot be run or its lock cannot be acquired
            $excludedJobs[] = $job->getId();

            // Remove it from the Entity Manager to free some memory
            $this->_em->detach($job);
        }
    }

    /**
     * Finds the next Job to process.
     *
     * @param array $excludedJobs The Jobs that have to be excluded from the SELECT
     *
     * @return Job|null
     */
    private function findNextJob(array $excludedJobs = [])
    {
        $queryBuilder = $this->getEntityManager()->createQueryBuilder();
        $queryBuilder->select('j')->from('SHQCommandsQueuesBundle:Job', 'j')
            ->orderBy('j.priority', 'ASC')
            ->addOrderBy('j.createdAt', 'ASC')
            // The status MUST be NEW
            ->where($queryBuilder->expr()->eq('j.status', ':status'))->setParameter('status', Job::STATUS_NEW)
            // It hasn't an executeAfterTime set or the set time is in the past
            ->andWhere(
                $queryBuilder->expr()->orX(
                    $queryBuilder->expr()->isNull('j.executeAfterTime'),
                    $queryBuilder->expr()->lt('j.executeAfterTime', ':now')
                )
            )->setParameter('now', new \DateTime(), 'datetime');

        // If there are excluded Jobs...
        if (false === empty($excludedJobs)) {
            // The ID hasn't to be one of them
            $queryBuilder->andWhere(
                $queryBuilder->expr()->notIn('j.id', ':excludedJobs')
            )->setParameter('excludedJobs', $excludedJobs, Connection::PARAM_INT_ARRAY);
        }

        return $queryBuilder->getQuery()->setMaxResults(1)->getOneOrNullResult();
    }
}

The repository is simply specified in the @Entity annotation in the Job entity class.

TESTS

Proving the two EntityManagers are different

TEST 1. Persist again in the EntityManager I'm using in services: works, the exception is not thrown anymore

This forces me to do something like this:

        ...

        // SerendipityHQ\Bundle\CommandsQueuesBundle\Service\QueuesDaemon
        if ($job->isRetry()) {
            // Here I have to persist AGAIN in my injected entity manager the Entity referenced by Job#retryOf
            $this->entityManager->persist($job->getRetryOf());
            ...
        }

        ...

If I don't do this, the exception is again thrown.

So it seems that the annotations loads entities using an EntityManager and my service, instead, use the entity manager I've specified... This is very strange and never happened to me! O.O

TEST 2. VarDump: Shows two different unique identifiers

With a simple VarDumper::dump($passedEntityManager) and VarDumper::dump($entity) I've discovered that they use two different entity managers:

        ...

        // SerendipityHQ\Bundle\CommandsQueuesBundle\Service\QueuesDaemon
        if ($job->isRetry()) {
            //$job->setRetryOf($this->entityManager->getRepository('SHQCommandsQueuesBundle:Job')->findOneById($job->getRetryOf()->getId()));
            VarDumper::dump($this->entityManager);
            VarDumper::dump($job);
            die;
            ...
        }

        ...

The result is:

EntityManager58a434fb99fbf_546a8d27f194334ee012bfe64f629947b07e4919\__CG__\Doctrine\ORM\EntityManager {#768
  -delegate: DoctrineORMEntityManager_000000007aac762a000000007cf8d85284b5df468960200ce73b9230d68d81c1 {#773 …2}
  -container: appDevDebugProjectContainer {#495 …12}
  -config: null
  -conn: null
  -metadataFactory: null
  -unitOfWork: null
  -eventManager: null
  -proxyFactory: null
  -repositoryFactory: null
  -expressionBuilder: null
  -closed: false
  -filterCollection: null
  -cache: null
}
SerendipityHQ\Bundle\CommandsQueuesBundle\Entity\Job {#730
  -id: 3
  ...
  -childDependencies: Doctrine\ORM\PersistentCollection {#41028
    ...
    -em: Doctrine\ORM\EntityManager {#788 …11}
    ...
    #initialized: true
  }
  -parentDependencies: Doctrine\ORM\PersistentCollection {#41019
    ...
    -em: Doctrine\ORM\EntityManager {#788 …11}
    ...
    #initialized: true
  }
    ...
    -processedBy: SerendipityHQ\Bundle\CommandsQueuesBundle\Entity\Daemon {#806
      ...
      -processedJobs: Doctrine\ORM\PersistentCollection {#819
        ...
        -em: Doctrine\ORM\EntityManager {#788 …11}
        ...
        #initialized: true
      }
    }
    ...
    -childDependencies: Doctrine\ORM\PersistentCollection {#40899
      ...
      -em: Doctrine\ORM\EntityManager {#788 …11}
      ...
      #initialized: false
    }
    -parentDependencies: Doctrine\ORM\PersistentCollection {#40901
      ...
      -em: Doctrine\ORM\EntityManager {#788 …11}
      ...
      #initialized: true
    }
    ...
}

In entities the Entity manager is #788:

SerendipityHQ\Bundle\CommandsQueuesBundle\Entity\Job {#725
  -id: 3
  -command: "queues:test"

...

  -childDependencies: Doctrine\ORM\PersistentCollection {#41028
    -snapshot: []
    -owner: SerendipityHQ\Bundle\CommandsQueuesBundle\Entity\Job {#725}
    -association: array:16 [ …16]
    -em: Doctrine\ORM\EntityManager {#788 …11}

while my injected entity manager is 768:

EntityManager58a434fb99fbf_546a8d27f194334ee012bfe64f629947b07e4919\__CG__\Doctrine\ORM\EntityManager  {#768
  -delegate: DoctrineORMEntityManager_000000007aac762a000000007cf8d85284b5df468960200ce73b9230d68d81c1. {#773 …2}

TEST 3. Proving I have more than one EntityManager: I have

Aerendir$ app/console debug:container | grep EntityManager
  aws_ses_monitor.entity_manager               Doctrine\ORM\EntityManager
  commands_queues.do_not_use.entity_manager    Doctrine\ORM\EntityManager
  doctrine.orm.default_entity_manager          EntityManager58a434fb99fbf_546a8d27f194334ee012bfe64f629947b07e4919\__CG__\Doctrine\ORM\EntityManager
  stripe_bundle.entity_manager                 Doctrine\ORM\EntityManager 

This is right as I effectively have 4 entity managers and the ones reported are the ones I expect exist.

TEST 4. Identify which EntityManager is used by my services and which one is used in my repositories

        // SerendipityHQ\Bundle\CommandsQueuesBundle\Service\QueuesDaemon
        if ($job->isRetry()) {
            VarDumper::dump('entityManager');
            VarDumper::dump(spl_object_hash($this->entityManager));
            VarDumper::dump('aws_ses_monitor.entity_manager');
            VarDumper::dump(spl_object_hash($this->container->get('aws_ses_monitor.entity_manager')));
            VarDumper::dump('commands_queues.do_not_use.entity_manager');
            VarDumper::dump(spl_object_hash($this->container->get('commands_queues.do_not_use.entity_manager')));
            VarDumper::dump('doctrine.orm.default_entity_manager');
            VarDumper::dump(spl_object_hash($this->container->get('doctrine.orm.default_entity_manager')));
            VarDumper::dump('stripe_bundle.entity_manager');
            VarDumper::dump(spl_object_hash($this->container->get('stripe_bundle.entity_manager')));

die; ... }

And in the JobRepository class:

private function findNextJob(array $excludedJobs = [])
{
    VarDumper::dump('Inside repository');
    VarDumper::dump(spl_object_hash($this->getEntityManager()));
...

Result:

"Inside repository"
"00000000326f44a000000000730f8ec4"                                     
"entityManager"
"00000000326f44b400000000730f8ec4"
"aws_ses_monitor.entity_manager"
"00000000326f44b400000000730f8ec4"
"commands_queues.do_not_use.entity_manager"
"00000000326f44b400000000730f8ec4"
"doctrine.orm.default_entity_manager"
"00000000326f44b400000000730f8ec4"
"stripe_bundle.entity_manager"
"00000000326f44b400000000730f8ec4"

It's ever the same! This is really unexpected!!! If the EntityManager is ever the same:

  1. Why the Job#retryOf object is not already persisted and I have to persist it again to not get the thrown exception?
  2. Why the VarDumper gives me two different internal object handles if the object is ever the same?

回答1:

TEST 5. Try to remove all the calls to $this->entityManager->detach()

SOLVED! The problem was a detach() that didn't check for the status: if a Job failed I simply detached it to not have it floating around in the EntityManager and as the bundle launches a daemon, I need to free up memory. But I need to free it up only if the Job is definitely failed, while if it has to be retried, I not have to detach it... Or at least I have to reload it if the processing Job refers to it...

Damn, building a daemon requires a lot of attention to details!