Add locks when creating short URL dependencies, to avoid race condition

This commit is contained in:
Alejandro Celaya
2023-05-21 18:08:17 +02:00
parent ac0ff8fb94
commit e85d59c5a4
3 changed files with 64 additions and 6 deletions

View File

@@ -11,7 +11,11 @@ use Doctrine\ORM\Events;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Symfony\Component\Lock\Lock;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\InMemoryStore;
use function Functional\invoke;
use function Functional\map;
use function Functional\unique;
@@ -21,10 +25,15 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
private array $memoizedNewDomains = [];
/** @var array<string, Tag> */
private array $memoizedNewTags = [];
/** @var array<string, Lock> */
private array $tagLocks = [];
/** @var array<string, Lock> */
private array $domainLocks = [];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UrlShortenerOptions $options = new UrlShortenerOptions(),
private readonly LockFactory $locker = new LockFactory(new InMemoryStore()),
) {
// Registering this as an event listener will make the postFlush method to be called automatically
$this->em->getEventManager()->addEventListener(Events::postFlush, $this);
@@ -36,11 +45,18 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
return null;
}
$this->lock($this->domainLocks, 'domain_' . $domain);
/** @var Domain|null $existingDomain */
$existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]);
if ($existingDomain) {
// The lock can be released immediately of the domain is not new
$this->releaseLock($this->domainLocks, 'domain_' . $domain);
return $existingDomain;
}
// Memoize only new domains, and let doctrine handle objects hydrated from persistence
return $existingDomain ?? $this->memoizeNewDomain($domain);
return $this->memoizeNewDomain($domain);
}
private function memoizeNewDomain(string $domain): Domain
@@ -62,8 +78,16 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
$repo = $this->em->getRepository(Tag::class);
return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag {
$this->lock($this->tagLocks, 'tag_' . $tagName);
$existingTag = $repo->findOneBy(['name' => $tagName]);
if ($existingTag) {
$this->releaseLock($this->tagLocks, 'tag_' . $tagName);
return $existingTag;
}
// Memoize only new tags, and let doctrine handle objects hydrated from persistence
$tag = $repo->findOneBy(['name' => $tagName]) ?? $this->memoizeNewTag($tagName);
$tag = $this->memoizeNewTag($tagName);
$this->em->persist($tag);
return $tag;
@@ -75,9 +99,36 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
return $this->memoizedNewTags[$tagName] ??= new Tag($tagName);
}
/**
* @param array<string, Lock> $locks
*/
private function lock(array &$locks, string $name): void
{
// Lock dependency creation for up to 5 seconds. This will prevent errors when trying to create the same one
// more than once in parallel.
$locks[$name] = $lock = $this->locker->createLock($name, 5);
$lock->acquire(true);
}
/**
* @param array<string, Lock> $locks
*/
private function releaseLock(array &$locks, string $name): void
{
$locks[$name]->release();
unset($locks[$name]);
}
public function postFlush(): void
{
// Reset memoized domains and tags
$this->memoizedNewDomains = [];
$this->memoizedNewTags = [];
// Release all locks
invoke($this->tagLocks, 'release');
invoke($this->domainLocks, 'release');
$this->tagLocks = [];
$this->domainLocks = [];
}
}