How to use UniqueEntity safely (on sites with more than one concurrent user)

Can someone smart use the common design pattern that they use to avoid this basic and general concurrency problem in Doctrine \ Symfony?

Scenario: Each user must have a unique username.

Bad solution:

Why is this happening:. Between checking and saving the user, the username can be accepted by another user. If so, Doctrine throws a UniqueConstraintViolationException exception when it tries to save a new user.

+8
php concurrency symfony doctrine2
source share
4 answers

Here is my next answer:

  • It correctly displays errors to the user if a restriction violation occurs, for example, if the handler processed it,

  • This prevents the "protection" of database updates in order to break the controller logic (for example, using the UPDATE statement or submitting a form with "insecure" controllers),

  • It is a database independent solution.

Here is the code with comments commentary:

<?php // ... use Doctrine\DBAL\Exception\ConstraintViolationException; use Symfony\Component\Form\FormError; use Symfony\Component\Form\Extension\Validator\ViolationMapper\ViolationMapper; // ... public function indexAction(Request $request) { $task = new Task(); $form = $this->createFormBuilder($task) ->add('name', TextType::class) ->add('save', SubmitType::class, array('label' => 'Create Task')) ->getForm(); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $task = $form->getData(); $em = $this->getDoctrine()->getManager(); $em->persist($task); try { $em->flush(); // Everything went well, do whatever you're supposed to. return $this->redirectToRoute('task_success'); } catch (ConstraintViolationException $e) { // Reopen the entity manager so the validator can do jobs // that needs to be performed with the database (in example: // unique constraint checks) $em = $em->create($em->getConnection(), $em->getConfiguration()); // Revalidate the form to see if the validator knows what // has thrown this constraint violation exception. $violations = $this->get('validator')->validate($form); if (empty($violations)) { // The validator didn't see anything wrong... // It can happens if you have a constraint on your table, // but didn't add a similar validation constraint. // Add an error at the root of the form. $form->add(new FormError('Unexpected error, please retry.')); } else { // Add errors to the form with the ViolationMapper. // The ViolationMapper will links error with its // corresponding field on the form. // So errors are not displayed at the root of the form, // just like if the form was validated natively. $violationMapper = new ViolationMapper(); foreach ($violations as $violation) { $violationMapper->mapViolation($violation, $form); } } } } return $this->render('default/new.html.twig', array( 'form' => $form->createView(), )); } 
+2
source share

One way to achieve what you want is to lock with symfony LockHandler .

Here is a simple example using the template that you are linking to your question:

 <?php // ... use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Filesystem\LockHandler; use Symfony\Component\Form\FormError; public function newAction(Request $request) { $task = new Task(); $form = $this->createFormBuilder($task) ->add('task', TextType::class) ->add('dueDate', DateType::class) ->add('save', SubmitType::class, array('label' => 'Create Task')) ->getForm(); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // locking here $lock = new LockHandler('task_validator.lock'); $lock->lock(); // since entity is validated when the form is submitted, you // have to call the validator manually $validator = $this->get('validator'); if (empty($validator->validate($task))) { $task = $form->getData(); $em = $this->getDoctrine()->getManager(); $em->persist($task); $em->flush(); // lock is released by garbage collector return $this->redirectToRoute('task_success'); } $form->addError(new FormError('An error occured, please retry')); // explicit release here to avoid keeping the Lock too much time. $lock->release(); } return $this->render('default/new.html.twig', array( 'form' => $form->createView(), )); } 

Note. This will not work if you run the application on multiple hosts from the documentation:

The lock handler only works if you use only one server. If you have multiple hosts, you should not use this helper.

You can also override EntityManager to create a new function, such as validateAndFlush($entity) , which controls the LockHandler and the validation process itself.

+1
source share

Failed to set unique restriction at database level. You can also check the Doctrine2 Documentation on how to do this:

 /** * @Entity * @Table(name="user", * uniqueConstraints={@UniqueConstraint(name="username_unique", columns={"username"})}, * ) */ class User { //... /** * @var string * @Column(type="string", name="username", nullable=false) */ protected $username; //... } 

Now you have a unique restriction at the database level (therefore, the same username can never be inserted into the user table twice).

When you perform an insert operation, you will get an exception that UniqueConstraintViolationException if the username already exists (a UniqueConstraintViolationException ). You can catch the exception and return a valid response to the client, where you report that this username has already been used (it was in your database).

0
source share

If I understand the question correctly, you have set a very high bar for yourself. Obviously, your resilience layer does not see the future. Therefore, it is not possible to maintain a validator that ensures that the insert is successful (and not throw a UniqueConstraintViolationException) using only your domain objects. Somewhere you will need to maintain an extra state.

If you need some incremental improvement, you need to somehow reserve a username during verification. Of course, this is quite simple - you just create a list somewhere to keep track of usernames on the fly, and check this list in addition to checking the persistence level during validation.

Where this is difficult, a reasonable way should be developed to crop this list and release usernames that are sent for verification but have never been used for successful registration.

This is an implementation detail, and you will need to think about how long the username is stored.

A simple implementation from my head: maintain a table in your database with (username, session_id, reserved_at) and periodically process all rows where reserved_at <:. date and time

You will need to keep track of session_id, since you are backing up a username for a specific user. Since the user has not yet created an account, the only way to identify them is through the session identifier.

0
source share

All Articles