From 41e2031e6b7b572c00aea53136baaef38cd79c86 Mon Sep 17 00:00:00 2001 From: Philipp Holzer Date: Tue, 13 Aug 2019 21:20:41 +0200 Subject: [PATCH] Console Lock WIP --- src/Console/Lock.php | 185 ++++++++++++++++++++++ src/Core/Console.php | 2 + src/Core/Lock/CacheLock.php | 49 +++++- src/Core/Lock/DatabaseLock.php | 41 ++++- src/Core/Lock/ILock.php | 20 ++- src/Core/Lock/Lock.php | 13 +- src/Core/Lock/SemaphoreLock.php | 87 ++++++++-- tests/src/Core/Lock/LockTest.php | 44 ++++- tests/src/Core/Lock/SemaphoreLockTest.php | 4 +- 9 files changed, 420 insertions(+), 25 deletions(-) create mode 100644 src/Console/Lock.php diff --git a/src/Console/Lock.php b/src/Console/Lock.php new file mode 100644 index 0000000000..fe9132b7cb --- /dev/null +++ b/src/Console/Lock.php @@ -0,0 +1,185 @@ +, Hypolite Petovan + */ +class Lock extends \Asika\SimpleConsole\Console +{ + protected $helpOptions = ['h', 'help', '?']; + + /** + * @var App\Mode + */ + private $appMode; + + /** + * @var ILock + */ + private $lock; + + protected function getHelp() + { + $help = <<] [-h|--help|-?] [-v] + bin/console lock set [ []] [-h|--help|-?] [-v] + bin/console lock del [-h|--help|-?] [-v] + bin/console lock clear [-h|--help|-?] [-v] + +Description + bin/console lock list [] + List all locks, optionally filtered by a prefix + + bin/console lock set [ []] + Sets manually a lock, optionally with the provided TTL (time to live) with a default of five minutes. + + bin/console lock del + Deletes a lock. + + bin/console lock clear + Clears all locks + +Options + -h|--help|-? Show help information + -v Show more debug information. +HELP; + return $help; + } + + public function __construct(App\Mode $appMode, ILock $lock, array $argv = null) + { + parent::__construct($argv); + + $this->appMode = $appMode; + $this->lock = $lock; + } + + protected function doExecute() + { + if ($this->getOption('v')) { + $this->out('Executable: ' . $this->executable); + $this->out('Class: ' . __CLASS__); + $this->out('Arguments: ' . var_export($this->args, true)); + $this->out('Options: ' . var_export($this->options, true)); + } + + if (!$this->appMode->has(App\Mode::DBCONFIGAVAILABLE)) { + $this->out('Database isn\'t ready or populated yet, database cache won\'t be available'); + } + + if ($this->getOption('v')) { + $this->out('Lock Driver Name: ' . $this->lock->getName()); + $this->out('Lock Driver Class: ' . get_class($this->lock)); + } + + switch ($this->getArgument(0)) { + case 'list': + $this->executeList(); + break; + case 'set': + $this->executeSet(); + break; + case 'del': + $this->executeDel(); + break; + case 'clear': + $this->executeClear(); + break; + } + + if (count($this->args) == 0) { + $this->out($this->getHelp()); + return 0; + } + + return 0; + } + + private function executeList() + { + $prefix = $this->getArgument(1, ''); + $keys = $this->lock->getLocks($prefix); + + if (empty($prefix)) { + $this->out('Listing all Locks:'); + } else { + $this->out('Listing all Locks starting with "' . $prefix . '":'); + } + + $count = 0; + foreach ($keys as $key) { + $this->out($key); + $count++; + } + + $this->out($count . ' locks found'); + } + + private function executeDel() + { + if (count($this->args) >= 2) { + $lock = $this->getArgument(1); + + if ($this->lock->releaseLock($lock, true)){ + $this->out(sprintf('Lock \'%s\' released.', $lock)); + } else { + $this->out(sprintf('Couldn\'t release Lock \'%s\'', $lock)); + } + + } else { + throw new CommandArgsException('Too few arguments for del.'); + } + } + + private function executeSet() + { + if (count($this->args) >= 2) { + $lock = $this->getArgument(1); + $timeout = intval($this->getArgument(2, false)); + $ttl = intval($this->getArgument(3, false)); + + if (is_array($this->lock->isLocked($lock))) { + throw new RuntimeException(sprintf('\'%s\' is already set.', $lock)); + } + + if (!empty($ttl) && !empty($timeout)) { + $result = $this->lock->acquireLock($lock, $timeout, $ttl); + } elseif (!empty($timeout)) { + $result = $this->lock->acquireLock($lock, $timeout); + } else { + $result = $this->lock->acquireLock($lock); + } + + if ($result) { + $this->out(sprintf('Lock \'%s\' acquired.', $lock)); + } else { + $this->out(sprintf('Unable to lock \'%s\'', $lock)); + } + } else { + throw new CommandArgsException('Too few arguments for set.'); + } + } + + private function executeClear() + { + $result = $this->lock->releaseAll(true); + if ($result) { + $this->out('Locks successfully cleared,'); + } else { + $this->out('Unable to clear the locks.'); + } + } +} diff --git a/src/Core/Console.php b/src/Core/Console.php index e1654fbef6..2ca568c2da 100644 --- a/src/Core/Console.php +++ b/src/Core/Console.php @@ -38,6 +38,7 @@ Commands: archivecontact Archive a contact when you know that it isn't existing anymore help Show help about a command, e.g (bin/console help config) autoinstall Starts automatic installation of friendica based on values from htconfig.php + lock Edit site locks maintenance Set maintenance mode for this node newpassword Set a new password for a given user php2po Generate a messages.po file from a strings.php file @@ -65,6 +66,7 @@ HELP; 'globalcommunitysilence' => Friendica\Console\GlobalCommunitySilence::class, 'archivecontact' => Friendica\Console\ArchiveContact::class, 'autoinstall' => Friendica\Console\AutomaticInstallation::class, + 'lock' => Friendica\Console\Lock::class, 'maintenance' => Friendica\Console\Maintenance::class, 'newpassword' => Friendica\Console\NewPassword::class, 'php2po' => Friendica\Console\PhpToPo::class, diff --git a/src/Core/Lock/CacheLock.php b/src/Core/Lock/CacheLock.php index 36a7b4edfb..238beb705c 100644 --- a/src/Core/Lock/CacheLock.php +++ b/src/Core/Lock/CacheLock.php @@ -7,6 +7,11 @@ use Friendica\Core\Cache\IMemoryCache; class CacheLock extends Lock { + /** + * @var string The static prefix of all locks inside the cache + */ + const CACHE_PREFIX = 'lock:'; + /** * @var \Friendica\Core\Cache\ICache; */ @@ -25,7 +30,7 @@ class CacheLock extends Lock /** * (@inheritdoc) */ - public function acquireLock($key, $timeout = 120, $ttl = Cache::FIVE_MINUTES) + public function acquireLock($key, $timeout = 120, $ttl = Cache\Cache::FIVE_MINUTES) { $got_lock = false; $start = time(); @@ -85,6 +90,46 @@ class CacheLock extends Lock return isset($lock) && ($lock !== false); } + /** + * {@inheritDoc} + */ + public function getName() + { + return $this->cache->getName(); + } + + /** + * {@inheritDoc} + */ + public function getLocks(string $prefix = '') + { + $locks = $this->cache->getAllKeys(self::CACHE_PREFIX . $prefix); + + array_walk($locks, function (&$lock, $key) { + $lock = substr($lock, strlen(self::CACHE_PREFIX)); + }); + + return $locks; + } + + /** + * {@inheritDoc} + */ + public function releaseAll($override = false) + { + $success = parent::releaseAll($override); + + $locks = $this->getLocks(); + + foreach ($locks as $lock) { + if (!$this->releaseLock($lock, $override)) { + $success = false; + } + } + + return $success; + } + /** * @param string $key The original key * @@ -92,6 +137,6 @@ class CacheLock extends Lock */ private static function getLockKey($key) { - return "lock:" . $key; + return self::CACHE_PREFIX . $key; } } diff --git a/src/Core/Lock/DatabaseLock.php b/src/Core/Lock/DatabaseLock.php index e5274b9b9b..2f409cd3d2 100644 --- a/src/Core/Lock/DatabaseLock.php +++ b/src/Core/Lock/DatabaseLock.php @@ -92,9 +92,16 @@ class DatabaseLock extends Lock /** * (@inheritdoc) */ - public function releaseAll() + public function releaseAll($override = false) { - $return = $this->dba->delete('locks', ['pid' => $this->pid]); + $success = parent::releaseAll($override); + + if ($override) { + $where = ['1 = 1']; + } else { + $where = ['pid' => $this->pid]; + } + $return = $this->dba->delete('locks', $where); $this->acquiredLocks = []; @@ -114,4 +121,34 @@ class DatabaseLock extends Lock return false; } } + + /** + * {@inheritDoc} + */ + public function getName() + { + return self::TYPE_DATABASE; + } + + /** + * {@inheritDoc} + */ + public function getLocks(string $prefix = '') + { + if (empty($prefix)) { + $where = ['`expires` >= ?', DateTimeFormat::utcNow()]; + } else { + $where = ['`expires` >= ? AND `k` LIKE CONCAT(?, \'%\')', DateTimeFormat::utcNow(), $prefix]; + } + + $stmt = $this->dba->select('locks', ['name'], $where); + + $keys = []; + while ($key = $this->dba->fetch($stmt)) { + array_push($keys, $key['name']); + } + $this->dba->close($stmt); + + return $keys; + } } diff --git a/src/Core/Lock/ILock.php b/src/Core/Lock/ILock.php index 0b91daeb56..d103d99191 100644 --- a/src/Core/Lock/ILock.php +++ b/src/Core/Lock/ILock.php @@ -45,7 +45,25 @@ interface ILock /** * Releases all lock that were set by us * + * @param bool $override Override to release all locks + * * @return boolean Was the unlock of all locks successful? */ - public function releaseAll(); + public function releaseAll($override = false); + + /** + * Returns the name of the current lock + * + * @return string + */ + public function getName(); + + /** + * Lists all locks + * + * @param string prefix optional a prefix to search + * + * @return array Empty if it isn't supported by the cache driver + */ + public function getLocks(string $prefix = ''); } diff --git a/src/Core/Lock/Lock.php b/src/Core/Lock/Lock.php index 4418fee271..f03ffe03d1 100644 --- a/src/Core/Lock/Lock.php +++ b/src/Core/Lock/Lock.php @@ -2,6 +2,8 @@ namespace Friendica\Core\Lock; +use Friendica\Core\Cache\Cache; + /** * Class AbstractLock * @@ -11,6 +13,9 @@ namespace Friendica\Core\Lock; */ abstract class Lock implements ILock { + const TYPE_DATABASE = Cache::TYPE_DATABASE; + const TYPE_SEMAPHORE = 'semaphore'; + /** * @var array The local acquired locks */ @@ -49,16 +54,14 @@ abstract class Lock implements ILock } /** - * Releases all lock that were set by us - * - * @return boolean Was the unlock of all locks successful? + * {@inheritDoc} */ - public function releaseAll() + public function releaseAll($override = false) { $return = true; foreach ($this->acquiredLocks as $acquiredLock => $hasLock) { - if (!$this->releaseLock($acquiredLock)) { + if (!$this->releaseLock($acquiredLock, $override)) { $return = false; } } diff --git a/src/Core/Lock/SemaphoreLock.php b/src/Core/Lock/SemaphoreLock.php index 789c9e8eca..75c7284a5f 100644 --- a/src/Core/Lock/SemaphoreLock.php +++ b/src/Core/Lock/SemaphoreLock.php @@ -20,9 +20,7 @@ class SemaphoreLock extends Lock */ private static function semaphoreKey($key) { - $temp = get_temppath(); - - $file = $temp . '/' . $key . '.sem'; + $file = self::keyToFile($key); if (!file_exists($file)) { file_put_contents($file, $key); @@ -31,10 +29,24 @@ class SemaphoreLock extends Lock return ftok($file, 'f'); } + /** + * Returns the full path to the semaphore file + * + * @param string $key The key of the semaphore + * + * @return string The full path + */ + private static function keyToFile($key) + { + $temp = get_temppath(); + + return $temp . '/' . $key . '.sem'; + } + /** * (@inheritdoc) */ - public function acquireLock($key, $timeout = 120, $ttl = Cache::FIVE_MINUTES) + public function acquireLock($key, $timeout = 120, $ttl = Cache\Cache::FIVE_MINUTES) { self::$semaphore[$key] = sem_get(self::semaphoreKey($key)); if (self::$semaphore[$key]) { @@ -52,14 +64,24 @@ class SemaphoreLock extends Lock */ public function releaseLock($key, $override = false) { - if (empty(self::$semaphore[$key])) { - return false; - } else { - $success = @sem_release(self::$semaphore[$key]); - unset(self::$semaphore[$key]); - $this->markRelease($key); - return $success; + $success = false; + + if (!empty(self::$semaphore[$key])) { + try { + $success = @sem_release(self::$semaphore[$key]) && + unlink(self::keyToFile($key)); + unset(self::$semaphore[$key]); + $this->markRelease($key); + } catch (\Exception $exception) { + $success = false; + } + } else if ($override) { + if ($this->acquireLock($key)) { + $success = $this->releaseLock($key, true); + } } + + return $success; } /** @@ -69,4 +91,47 @@ class SemaphoreLock extends Lock { return isset(self::$semaphore[$key]); } + + /** + * {@inheritDoc} + */ + public function getName() + { + return self::TYPE_SEMAPHORE; + } + + /** + * {@inheritDoc} + */ + public function getLocks(string $prefix = '') + { + $temp = get_temppath(); + $locks = []; + foreach (glob(sprintf('%s/%s*.sem', $temp, $prefix)) as $lock) { + $lock = pathinfo($lock, PATHINFO_FILENAME); + if(sem_get(self::semaphoreKey($lock))) { + $locks[] = $lock; + } + } + + return $locks; + } + + /** + * {@inheritDoc} + */ + public function releaseAll($override = false) + { + $success = parent::releaseAll($override); + + $temp = get_temppath(); + foreach (glob(sprintf('%s/*.sem', $temp)) as $lock) { + $lock = pathinfo($lock, PATHINFO_FILENAME); + if (!$this->releaseLock($lock, true)) { + $success = false; + } + } + + return $success; + } } diff --git a/tests/src/Core/Lock/LockTest.php b/tests/src/Core/Lock/LockTest.php index 0c231713ae..dd38172b38 100644 --- a/tests/src/Core/Lock/LockTest.php +++ b/tests/src/Core/Lock/LockTest.php @@ -23,12 +23,12 @@ abstract class LockTest extends MockedTest parent::setUp(); $this->instance = $this->getInstance(); - $this->instance->releaseAll(); + $this->instance->releaseAll(true); } protected function tearDown() { - $this->instance->releaseAll(); + $this->instance->releaseAll(true); parent::tearDown(); } @@ -123,6 +123,46 @@ abstract class LockTest extends MockedTest $this->assertFalse($this->instance->isLocked('test')); } + /** + * @small + */ + public function testGetLocks() + { + $this->assertTrue($this->instance->acquireLock('foo', 1)); + $this->assertTrue($this->instance->acquireLock('bar', 1)); + $this->assertTrue($this->instance->acquireLock('nice', 1)); + + $this->assertTrue($this->instance->isLocked('foo')); + $this->assertTrue($this->instance->isLocked('bar')); + $this->assertTrue($this->instance->isLocked('nice')); + + $locks = $this->instance->getLocks(); + + $this->assertContains('foo', $locks); + $this->assertContains('bar', $locks); + $this->assertContains('nice', $locks); + } + + /** + * @small + */ + public function testGetLocksWithPrefix() + { + $this->assertTrue($this->instance->acquireLock('foo', 1)); + $this->assertTrue($this->instance->acquireLock('test1', 1)); + $this->assertTrue($this->instance->acquireLock('test2', 1)); + + $this->assertTrue($this->instance->isLocked('foo')); + $this->assertTrue($this->instance->isLocked('test1')); + $this->assertTrue($this->instance->isLocked('test2')); + + $locks = $this->instance->getLocks('test'); + + $this->assertContains('test1', $locks); + $this->assertContains('test2', $locks); + $this->assertNotContains('foo', $locks); + } + /** * @medium */ diff --git a/tests/src/Core/Lock/SemaphoreLockTest.php b/tests/src/Core/Lock/SemaphoreLockTest.php index 7b9b03d728..52c5aaa5b8 100644 --- a/tests/src/Core/Lock/SemaphoreLockTest.php +++ b/tests/src/Core/Lock/SemaphoreLockTest.php @@ -12,8 +12,6 @@ class SemaphoreLockTest extends LockTest { public function setUp() { - parent::setUp(); - $dice = \Mockery::mock(Dice::class)->makePartial(); $app = \Mockery::mock(App::class); @@ -29,6 +27,8 @@ class SemaphoreLockTest extends LockTest // @todo Because "get_temppath()" is using static methods, we have to initialize the BaseObject BaseObject::setDependencyInjection($dice); + + parent::setUp(); } protected function getInstance()