mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-25 18:45:27 -06:00
Add locks when creating short URL dependencies, to avoid race condition
This commit is contained in:
parent
ac0ff8fb94
commit
e85d59c5a4
@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ErrorHandler;
|
|||||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||||
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
|
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||||
|
use Symfony\Component\Lock;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@ -172,7 +173,11 @@ return [
|
|||||||
],
|
],
|
||||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
||||||
|
|
||||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em', Options\UrlShortenerOptions::class],
|
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [
|
||||||
|
'em',
|
||||||
|
Options\UrlShortenerOptions::class,
|
||||||
|
Lock\LockFactory::class,
|
||||||
|
],
|
||||||
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
||||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
||||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
|
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
|
||||||
|
@ -11,7 +11,11 @@ use Doctrine\ORM\Events;
|
|||||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
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\map;
|
||||||
use function Functional\unique;
|
use function Functional\unique;
|
||||||
|
|
||||||
@ -21,10 +25,15 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
|||||||
private array $memoizedNewDomains = [];
|
private array $memoizedNewDomains = [];
|
||||||
/** @var array<string, Tag> */
|
/** @var array<string, Tag> */
|
||||||
private array $memoizedNewTags = [];
|
private array $memoizedNewTags = [];
|
||||||
|
/** @var array<string, Lock> */
|
||||||
|
private array $tagLocks = [];
|
||||||
|
/** @var array<string, Lock> */
|
||||||
|
private array $domainLocks = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly UrlShortenerOptions $options = new UrlShortenerOptions(),
|
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
|
// Registering this as an event listener will make the postFlush method to be called automatically
|
||||||
$this->em->getEventManager()->addEventListener(Events::postFlush, $this);
|
$this->em->getEventManager()->addEventListener(Events::postFlush, $this);
|
||||||
@ -36,11 +45,18 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->lock($this->domainLocks, 'domain_' . $domain);
|
||||||
|
|
||||||
/** @var Domain|null $existingDomain */
|
/** @var Domain|null $existingDomain */
|
||||||
$existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]);
|
$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
|
// 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
|
private function memoizeNewDomain(string $domain): Domain
|
||||||
@ -62,8 +78,16 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
|||||||
$repo = $this->em->getRepository(Tag::class);
|
$repo = $this->em->getRepository(Tag::class);
|
||||||
|
|
||||||
return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag {
|
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
|
// 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);
|
$this->em->persist($tag);
|
||||||
|
|
||||||
return $tag;
|
return $tag;
|
||||||
@ -75,9 +99,36 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
|||||||
return $this->memoizedNewTags[$tagName] ??= new Tag($tagName);
|
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
|
public function postFlush(): void
|
||||||
{
|
{
|
||||||
|
// Reset memoized domains and tags
|
||||||
$this->memoizedNewDomains = [];
|
$this->memoizedNewDomains = [];
|
||||||
$this->memoizedNewTags = [];
|
$this->memoizedNewTags = [];
|
||||||
|
|
||||||
|
// Release all locks
|
||||||
|
invoke($this->tagLocks, 'release');
|
||||||
|
invoke($this->domainLocks, 'release');
|
||||||
|
$this->tagLocks = [];
|
||||||
|
$this->domainLocks = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -74,10 +74,12 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
|
|||||||
#[Test, DataProvider('provideTags')]
|
#[Test, DataProvider('provideTags')]
|
||||||
public function findsAndPersistsTagsWrappedIntoCollection(array $tags, array $expectedTags): void
|
public function findsAndPersistsTagsWrappedIntoCollection(array $tags, array $expectedTags): void
|
||||||
{
|
{
|
||||||
$expectedPersistedTags = count($expectedTags);
|
$expectedLookedOutTags = count($expectedTags);
|
||||||
|
// One of the tags will already exist. The rest will be new
|
||||||
|
$expectedPersistedTags = $expectedLookedOutTags - 1;
|
||||||
|
|
||||||
$tagRepo = $this->createMock(TagRepositoryInterface::class);
|
$tagRepo = $this->createMock(TagRepositoryInterface::class);
|
||||||
$tagRepo->expects($this->exactly($expectedPersistedTags))->method('findOneBy')->with(
|
$tagRepo->expects($this->exactly($expectedLookedOutTags))->method('findOneBy')->with(
|
||||||
$this->isType('array'),
|
$this->isType('array'),
|
||||||
)->willReturnCallback(function (array $criteria): ?Tag {
|
)->willReturnCallback(function (array $criteria): ?Tag {
|
||||||
['name' => $name] = $criteria;
|
['name' => $name] = $criteria;
|
||||||
@ -90,7 +92,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
|
|||||||
|
|
||||||
$result = $this->resolver->resolveTags($tags);
|
$result = $this->resolver->resolveTags($tags);
|
||||||
|
|
||||||
self::assertCount($expectedPersistedTags, $result);
|
self::assertCount($expectedLookedOutTags, $result);
|
||||||
self::assertEquals($expectedTags, $result->toArray());
|
self::assertEquals($expectedTags, $result->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user