Path: blob/master/src/infrastructure/util/PhabricatorGlobalLock.php
12241 views
<?php12/**3* Global, MySQL-backed lock. This is a high-reliability, low-performance4* global lock.5*6* The lock is maintained by using GET_LOCK() in MySQL, and automatically7* released when the connection terminates. Thus, this lock can safely be used8* to control access to shared resources without implementing any sort of9* timeout or override logic: the lock can't normally be stuck in a locked state10* with no process actually holding the lock.11*12* However, acquiring the lock is moderately expensive (several network13* roundtrips). This makes it unsuitable for tasks where lock performance is14* important.15*16* $lock = PhabricatorGlobalLock::newLock('example');17* $lock->lock();18* do_contentious_things();19* $lock->unlock();20*21* NOTE: This lock is not completely global; it is namespaced to the active22* storage namespace so that unit tests running in separate table namespaces23* are isolated from one another.24*25* @task construct Constructing Locks26* @task impl Implementation27*/28final class PhabricatorGlobalLock extends PhutilLock {2930private $parameters;31private $conn;32private $externalConnection;33private $log;34private $disableLogging;3536private static $pool = array();373839/* -( Constructing Locks )------------------------------------------------- */404142public static function newLock($name, $parameters = array()) {43$namespace = PhabricatorLiskDAO::getStorageNamespace();44$namespace = PhabricatorHash::digestToLength($namespace, 20);4546$parts = array();47ksort($parameters);48foreach ($parameters as $key => $parameter) {49if (!preg_match('/^[a-zA-Z0-9]+\z/', $key)) {50throw new Exception(51pht(52'Lock parameter key "%s" must be alphanumeric.',53$key));54}5556if (!is_scalar($parameter) && !is_null($parameter)) {57throw new Exception(58pht(59'Lock parameter for key "%s" must be a scalar.',60$key));61}6263$value = phutil_json_encode($parameter);64$parts[] = "{$key}={$value}";65}66$parts = implode(', ', $parts);6768$local = "{$name}({$parts})";69$local = PhabricatorHash::digestToLength($local, 20);7071$full_name = "ph:{$namespace}:{$local}";72$lock = self::getLock($full_name);73if (!$lock) {74$lock = new PhabricatorGlobalLock($full_name);75self::registerLock($lock);7677$lock->parameters = $parameters;78}7980return $lock;81}8283/**84* Use a specific database connection for locking.85*86* By default, `PhabricatorGlobalLock` will lock on the "repository" database87* (somewhat arbitrarily). In most cases this is fine, but this method can88* be used to lock on a specific connection.89*90* @param AphrontDatabaseConnection91* @return this92*/93public function setExternalConnection(AphrontDatabaseConnection $conn) {94if ($this->conn) {95throw new Exception(96pht(97'Lock is already held, and must be released before the '.98'connection may be changed.'));99}100$this->externalConnection = $conn;101return $this;102}103104public function setDisableLogging($disable) {105$this->disableLogging = $disable;106return $this;107}108109110/* -( Connection Pool )---------------------------------------------------- */111112public static function getConnectionPoolSize() {113return count(self::$pool);114}115116public static function clearConnectionPool() {117self::$pool = array();118}119120public static function newConnection() {121// NOTE: Use of the "repository" database is somewhat arbitrary, mostly122// because the first client of locks was the repository daemons.123124// We must always use the same database for all locks, because different125// databases may be on different hosts if the database is partitioned.126127// However, we don't access any tables so we could use any valid database.128// We could build a database-free connection instead, but that's kind of129// messy and unusual.130131$dao = new PhabricatorRepository();132133// NOTE: Using "force_new" to make sure each lock is on its own connection.134135// See T13627. This is critically important in versions of MySQL older136// than MySQL 5.7, because they can not hold more than one lock per137// connection simultaneously.138139return $dao->establishConnection('w', $force_new = true);140}141142/* -( Implementation )----------------------------------------------------- */143144protected function doLock($wait) {145$conn = $this->conn;146147if (!$conn) {148if ($this->externalConnection) {149$conn = $this->externalConnection;150}151}152153if (!$conn) {154// Try to reuse a connection from the connection pool.155$conn = array_pop(self::$pool);156}157158if (!$conn) {159$conn = self::newConnection();160}161162// See T13627. We must never hold more than one lock per connection, so163// make sure this connection has no existing locks. (Normally, we should164// only be able to get here if callers explicitly provide the same external165// connection to multiple locks.)166167if ($conn->isHoldingAnyLock()) {168throw new Exception(169pht(170'Unable to establish lock on connection: this connection is '.171'already holding a lock. Acquiring a second lock on the same '.172'connection would release the first lock in MySQL versions '.173'older than 5.7.'));174}175176// NOTE: Since MySQL will disconnect us if we're idle for too long, we set177// the wait_timeout to an enormous value, to allow us to hold the178// connection open indefinitely (or, at least, for 24 days).179$max_allowed_timeout = 2147483;180queryfx($conn, 'SET wait_timeout = %d', $max_allowed_timeout);181182$lock_name = $this->getName();183184$result = queryfx_one(185$conn,186'SELECT GET_LOCK(%s, %f)',187$lock_name,188$wait);189190$ok = head($result);191if (!$ok) {192193// See PHI1794. We failed to acquire the lock, but the connection itself194// is still good. We're done with it, so add it to the pool, just as we195// would if we were releasing the lock.196197// If we don't do this, we may establish a huge number of connections198// very rapidly if many workers try to acquire a lock at once. For199// example, this can happen if there are a large number of webhook tasks200// in the queue.201202// See T13627. If this is an external connection, don't put it into203// the shared connection pool.204205if (!$this->externalConnection) {206self::$pool[] = $conn;207}208209throw id(new PhutilLockException($lock_name))210->setHint($this->newHint($lock_name, $wait));211}212213$conn->rememberLock($lock_name);214215$this->conn = $conn;216217if ($this->shouldLogLock()) {218$lock_context = $this->newLockContext();219220$log = id(new PhabricatorDaemonLockLog())221->setLockName($lock_name)222->setLockParameters($this->parameters)223->setLockContext($lock_context)224->save();225226$this->log = $log;227}228}229230protected function doUnlock() {231$lock_name = $this->getName();232233$conn = $this->conn;234235try {236$result = queryfx_one(237$conn,238'SELECT RELEASE_LOCK(%s)',239$lock_name);240$conn->forgetLock($lock_name);241} catch (Exception $ex) {242$result = array(null);243}244245$ok = head($result);246if (!$ok) {247// TODO: We could throw here, but then this lock doesn't get marked248// unlocked and we throw again later when exiting. It also doesn't249// particularly matter for any current applications. For now, just250// swallow the error.251}252253$this->conn = null;254255if (!$this->externalConnection) {256$conn->close();257self::$pool[] = $conn;258}259260if ($this->log) {261$log = $this->log;262$this->log = null;263264$conn = $log->establishConnection('w');265queryfx(266$conn,267'UPDATE %T SET lockReleased = UNIX_TIMESTAMP() WHERE id = %d',268$log->getTableName(),269$log->getID());270}271}272273private function shouldLogLock() {274if ($this->disableLogging) {275return false;276}277278$policy = id(new PhabricatorDaemonLockLogGarbageCollector())279->getRetentionPolicy();280if (!$policy) {281return false;282}283284return true;285}286287private function newLockContext() {288$context = array(289'pid' => getmypid(),290'host' => php_uname('n'),291'sapi' => php_sapi_name(),292);293294global $argv;295if ($argv) {296$context['argv'] = $argv;297}298299$access_log = null;300301// TODO: There's currently no cohesive way to get the parameterized access302// log for the current request across different request types. Web requests303// have an "AccessLog", SSH requests have an "SSHLog", and other processes304// (like scripts) have no log. But there's no method to say "give me any305// log you've got". For now, just test if we have a web request and use the306// "AccessLog" if we do, since that's the only one we actually read any307// parameters from.308309// NOTE: "PhabricatorStartup" is only available from web requests, not310// from CLI scripts.311if (class_exists('PhabricatorStartup', false)) {312$access_log = PhabricatorAccessLog::getLog();313}314315if ($access_log) {316$controller = $access_log->getData('C');317if ($controller) {318$context['controller'] = $controller;319}320321$method = $access_log->getData('m');322if ($method) {323$context['method'] = $method;324}325}326327return $context;328}329330private function newHint($lock_name, $wait) {331if (!$this->shouldLogLock()) {332return pht(333'Enable the lock log for more detailed information about '.334'which process is holding this lock.');335}336337$now = PhabricatorTime::getNow();338339// First, look for recent logs. If other processes have been acquiring and340// releasing this lock while we've been waiting, this is more likely to be341// a contention/throughput issue than an issue with something hung while342// holding the lock.343$limit = 100;344$logs = id(new PhabricatorDaemonLockLog())->loadAllWhere(345'lockName = %s AND dateCreated >= %d ORDER BY id ASC LIMIT %d',346$lock_name,347($now - $wait),348$limit);349350if ($logs) {351if (count($logs) === $limit) {352return pht(353'During the last %s second(s) spent waiting for the lock, more '.354'than %s other process(es) acquired it, so this is likely a '.355'bottleneck. Use "bin/lock log --name %s" to review log activity.',356new PhutilNumber($wait),357new PhutilNumber($limit),358$lock_name);359} else {360return pht(361'During the last %s second(s) spent waiting for the lock, %s '.362'other process(es) acquired it, so this is likely a '.363'bottleneck. Use "bin/lock log --name %s" to review log activity.',364new PhutilNumber($wait),365phutil_count($logs),366$lock_name);367}368}369370$last_log = id(new PhabricatorDaemonLockLog())->loadOneWhere(371'lockName = %s ORDER BY id DESC LIMIT 1',372$lock_name);373374if ($last_log) {375$info = array();376377$acquired = $last_log->getDateCreated();378$context = $last_log->getLockContext();379380$process_info = array();381382$pid = idx($context, 'pid');383if ($pid) {384$process_info[] = 'pid='.$pid;385}386387$host = idx($context, 'host');388if ($host) {389$process_info[] = 'host='.$host;390}391392$sapi = idx($context, 'sapi');393if ($sapi) {394$process_info[] = 'sapi='.$sapi;395}396397$argv = idx($context, 'argv');398if ($argv) {399$process_info[] = 'argv='.(string)csprintf('%LR', $argv);400}401402$controller = idx($context, 'controller');403if ($controller) {404$process_info[] = 'controller='.$controller;405}406407$method = idx($context, 'method');408if ($method) {409$process_info[] = 'method='.$method;410}411412$process_info = implode(', ', $process_info);413414$info[] = pht(415'This lock was most recently acquired by a process (%s) '.416'%s second(s) ago.',417$process_info,418new PhutilNumber($now - $acquired));419420$released = $last_log->getLockReleased();421if ($released) {422$info[] = pht(423'This lock was released %s second(s) ago.',424new PhutilNumber($now - $released));425} else {426$info[] = pht('There is no record of this lock being released.');427}428429return implode(' ', $info);430}431432return pht(433'Found no records of processes acquiring or releasing this lock.');434}435436}437438439