diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index a85cb999..5212cf3b 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Db; use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; -use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; +use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Lock\LockFactory; @@ -30,8 +30,8 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand $this->processRunner->run($output, $command); } - protected function getLockConfig(): LockedCommandConfig + protected function getLockConfig(): LockConfig { - return LockedCommandConfig::blocking($this->getName() ?? static::class); + return LockConfig::blocking($this->getName() ?? static::class); } } diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index a4c3ef5d..bf58fb1b 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -39,5 +39,5 @@ abstract class AbstractLockedCommand extends Command abstract protected function lockedExecute(InputInterface $input, OutputInterface $output): int; - abstract protected function getLockConfig(): LockedCommandConfig; + abstract protected function getLockConfig(): LockConfig; } diff --git a/module/CLI/src/Command/Util/CommandUtils.php b/module/CLI/src/Command/Util/CommandUtils.php index 76085f1a..69158275 100644 --- a/module/CLI/src/Command/Util/CommandUtils.php +++ b/module/CLI/src/Command/Util/CommandUtils.php @@ -6,6 +6,9 @@ namespace Shlinkio\Shlink\CLI\Command\Util; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Lock\LockFactory; + +use function sprintf; class CommandUtils { @@ -25,4 +28,31 @@ class CommandUtils return $callback(); } + + /** + * Runs a callback with a lock, making sure the lock is released after running the callback, and the callback does + * not run if the lock is already acquired. + * + * @param callable(): int $callback + */ + public static function executeWithLock( + LockFactory $locker, + LockConfig $lockConfig, + SymfonyStyle $io, + callable $callback, + ): int { + $lock = $locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking); + if (! $lock->acquire($lockConfig->isBlocking)) { + $io->writeln( + sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName), + ); + return Command::INVALID; + } + + try { + return $callback(); + } finally { + $lock->release(); + } + } } diff --git a/module/CLI/src/Command/Util/LockedCommandConfig.php b/module/CLI/src/Command/Util/LockConfig.php similarity index 56% rename from module/CLI/src/Command/Util/LockedCommandConfig.php rename to module/CLI/src/Command/Util/LockConfig.php index a8834d92..8f8fb09c 100644 --- a/module/CLI/src/Command/Util/LockedCommandConfig.php +++ b/module/CLI/src/Command/Util/LockConfig.php @@ -4,24 +4,24 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Util; -final class LockedCommandConfig +final readonly class LockConfig { public const float DEFAULT_TTL = 600.0; // 10 minutes private function __construct( - public readonly string $lockName, - public readonly bool $isBlocking, - public readonly float $ttl = self::DEFAULT_TTL, + public string $lockName, + public bool $isBlocking, + public float $ttl = self::DEFAULT_TTL, ) { } public static function blocking(string $lockName): self { - return new self($lockName, true); + return new self($lockName, isBlocking: true); } public static function nonBlocking(string $lockName): self { - return new self($lockName, false); + return new self($lockName, isBlocking: false); } } diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 3ed2edf9..f2501a34 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; -use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; +use Shlinkio\Shlink\CLI\Command\Util\CommandUtils; +use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocatorInterface; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface; use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType; use Shlinkio\Shlink\IpGeolocation\Model\Location; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; @@ -26,7 +27,7 @@ use Throwable; use function sprintf; -class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface +class LocateVisitsCommand extends Command implements VisitGeolocationHelperInterface { public const string NAME = 'visit:locate'; @@ -35,9 +36,9 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat public function __construct( private readonly VisitLocatorInterface $visitLocator, private readonly VisitToLocationHelperInterface $visitToLocation, - LockFactory $locker, + private readonly LockFactory $locker, ) { - parent::__construct($locker); + parent::__construct(); } protected function configure(): void @@ -97,7 +98,17 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat return $this->io->confirm('Do you want to proceed?', false); } - protected function lockedExecute(InputInterface $input, OutputInterface $output): int + protected function execute(InputInterface $input, OutputInterface $output): int + { + return CommandUtils::executeWithLock( + $this->locker, + LockConfig::nonBlocking(self::NAME), + new SymfonyStyle($input, $output), + fn () => $this->runStuff($input), + ); + } + + private function runStuff(InputInterface $input): int { $retry = $input->getOption('retry'); $all = $retry && $input->getOption('all'); @@ -174,9 +185,4 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.'); } } - - protected function getLockConfig(): LockedCommandConfig - { - return LockedCommandConfig::nonBlocking(self::NAME); - } } diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index af9577ea..7e650f9d 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Util; use Closure; -use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; +use Shlinkio\Shlink\CLI\Command\Util\LockConfig; use Symfony\Component\Console\Helper\DebugFormatterHelper; use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Output\ConsoleOutputInterface; @@ -24,7 +24,7 @@ class ProcessRunner implements ProcessRunnerInterface { $this->createProcess = $createProcess !== null ? $createProcess(...) - : static fn (array $cmd) => new Process($cmd, timeout: LockedCommandConfig::DEFAULT_TTL); + : static fn (array $cmd) => new Process($cmd, timeout: LockConfig::DEFAULT_TTL); } public function run(OutputInterface $output, array $cmd): void