@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at recaptime-dev/main 437 lines 13 kB view raw
1<?php 2 3/** 4 * Global, MySQL-backed lock. This is a high-reliability, low-performance 5 * global lock. 6 * 7 * The lock is maintained by using GET_LOCK() in MySQL, and automatically 8 * released when the connection terminates. Thus, this lock can safely be used 9 * to control access to shared resources without implementing any sort of 10 * timeout or override logic: the lock can't normally be stuck in a locked state 11 * with no process actually holding the lock. 12 * 13 * However, acquiring the lock is moderately expensive (several network 14 * roundtrips). This makes it unsuitable for tasks where lock performance is 15 * important. 16 * 17 * $lock = PhabricatorGlobalLock::newLock('example'); 18 * $lock->lock(); 19 * do_contentious_things(); 20 * $lock->unlock(); 21 * 22 * NOTE: This lock is not completely global; it is namespaced to the active 23 * storage namespace so that unit tests running in separate table namespaces 24 * are isolated from one another. 25 * 26 * @task construct Constructing Locks 27 * @task impl Implementation 28 */ 29final class PhabricatorGlobalLock extends PhutilLock { 30 31 private $parameters; 32 private $conn; 33 private $externalConnection; 34 private $log; 35 private $disableLogging; 36 37 private static $pool = array(); 38 39 40/* -( Constructing Locks )------------------------------------------------- */ 41 42 43 public static function newLock($name, $parameters = array()) { 44 $namespace = PhabricatorLiskDAO::getStorageNamespace(); 45 $namespace = PhabricatorHash::digestToLength($namespace, 20); 46 47 $parts = array(); 48 ksort($parameters); 49 foreach ($parameters as $key => $parameter) { 50 if (!preg_match('/^[a-zA-Z0-9]+\z/', $key)) { 51 throw new Exception( 52 pht( 53 'Lock parameter key "%s" must be alphanumeric.', 54 $key)); 55 } 56 57 if (!is_scalar($parameter) && !is_null($parameter)) { 58 throw new Exception( 59 pht( 60 'Lock parameter for key "%s" must be a scalar.', 61 $key)); 62 } 63 64 $value = phutil_json_encode($parameter); 65 $parts[] = "{$key}={$value}"; 66 } 67 $parts = implode(', ', $parts); 68 69 $local = "{$name}({$parts})"; 70 $local = PhabricatorHash::digestToLength($local, 20); 71 72 $full_name = "ph:{$namespace}:{$local}"; 73 $lock = self::getLock($full_name); 74 if (!$lock) { 75 $lock = new PhabricatorGlobalLock($full_name); 76 self::registerLock($lock); 77 78 $lock->parameters = $parameters; 79 } 80 81 return $lock; 82 } 83 84 /** 85 * Use a specific database connection for locking. 86 * 87 * By default, `PhabricatorGlobalLock` will lock on the "repository" database 88 * (somewhat arbitrarily). In most cases this is fine, but this method can 89 * be used to lock on a specific connection. 90 * 91 * @param AphrontDatabaseConnection $conn 92 * @return $this 93 */ 94 public function setExternalConnection(AphrontDatabaseConnection $conn) { 95 if ($this->conn) { 96 throw new Exception( 97 pht( 98 'Lock is already held, and must be released before the '. 99 'connection may be changed.')); 100 } 101 $this->externalConnection = $conn; 102 return $this; 103 } 104 105 public function setDisableLogging($disable) { 106 $this->disableLogging = $disable; 107 return $this; 108 } 109 110 111/* -( Connection Pool )---------------------------------------------------- */ 112 113 public static function getConnectionPoolSize() { 114 return count(self::$pool); 115 } 116 117 public static function clearConnectionPool() { 118 self::$pool = array(); 119 } 120 121 public static function newConnection() { 122 // NOTE: Use of the "repository" database is somewhat arbitrary, mostly 123 // because the first client of locks was the repository daemons. 124 125 // We must always use the same database for all locks, because different 126 // databases may be on different hosts if the database is partitioned. 127 128 // However, we don't access any tables so we could use any valid database. 129 // We could build a database-free connection instead, but that's kind of 130 // messy and unusual. 131 132 $dao = new PhabricatorRepository(); 133 134 // NOTE: Using "force_new" to make sure each lock is on its own connection. 135 136 // See T13627. This is critically important in versions of MySQL older 137 // than MySQL 5.7, because they can not hold more than one lock per 138 // connection simultaneously. 139 140 return $dao->establishConnection('w', $force_new = true); 141 } 142 143/* -( Implementation )----------------------------------------------------- */ 144 145 protected function doLock($wait) { 146 $conn = $this->conn; 147 148 if (!$conn) { 149 if ($this->externalConnection) { 150 $conn = $this->externalConnection; 151 } 152 } 153 154 if (!$conn) { 155 // Try to reuse a connection from the connection pool. 156 $conn = array_pop(self::$pool); 157 } 158 159 if (!$conn) { 160 $conn = self::newConnection(); 161 } 162 163 // See T13627. We must never hold more than one lock per connection, so 164 // make sure this connection has no existing locks. (Normally, we should 165 // only be able to get here if callers explicitly provide the same external 166 // connection to multiple locks.) 167 168 if ($conn->isHoldingAnyLock()) { 169 throw new Exception( 170 pht( 171 'Unable to establish lock on connection: this connection is '. 172 'already holding a lock. Acquiring a second lock on the same '. 173 'connection would release the first lock in MySQL versions '. 174 'older than 5.7.')); 175 } 176 177 // NOTE: Since MySQL will disconnect us if we're idle for too long, we set 178 // the wait_timeout to an enormous value, to allow us to hold the 179 // connection open indefinitely (or, at least, for 24 days). 180 $max_allowed_timeout = 2147483; 181 queryfx($conn, 'SET wait_timeout = %d', $max_allowed_timeout); 182 183 $lock_name = $this->getName(); 184 185 $result = queryfx_one( 186 $conn, 187 'SELECT GET_LOCK(%s, %f)', 188 $lock_name, 189 $wait); 190 191 $ok = head($result); 192 if (!$ok) { 193 194 // See PHI1794. We failed to acquire the lock, but the connection itself 195 // is still good. We're done with it, so add it to the pool, just as we 196 // would if we were releasing the lock. 197 198 // If we don't do this, we may establish a huge number of connections 199 // very rapidly if many workers try to acquire a lock at once. For 200 // example, this can happen if there are a large number of webhook tasks 201 // in the queue. 202 203 // See T13627. If this is an external connection, don't put it into 204 // the shared connection pool. 205 206 if (!$this->externalConnection) { 207 self::$pool[] = $conn; 208 } 209 210 throw id(new PhutilLockException($lock_name)) 211 ->setHint($this->newHint($lock_name, $wait)); 212 } 213 214 $conn->rememberLock($lock_name); 215 216 $this->conn = $conn; 217 218 if ($this->shouldLogLock()) { 219 $lock_context = $this->newLockContext(); 220 221 $log = id(new PhabricatorDaemonLockLog()) 222 ->setLockName($lock_name) 223 ->setLockParameters($this->parameters) 224 ->setLockContext($lock_context) 225 ->save(); 226 227 $this->log = $log; 228 } 229 } 230 231 protected function doUnlock() { 232 $lock_name = $this->getName(); 233 234 $conn = $this->conn; 235 236 try { 237 $result = queryfx_one( 238 $conn, 239 'SELECT RELEASE_LOCK(%s)', 240 $lock_name); 241 $conn->forgetLock($lock_name); 242 } catch (Exception $ex) { 243 $result = array(null); 244 } 245 246 $ok = head($result); 247 if (!$ok) { 248 // TODO: We could throw here, but then this lock doesn't get marked 249 // unlocked and we throw again later when exiting. It also doesn't 250 // particularly matter for any current applications. For now, just 251 // swallow the error. 252 } 253 254 $this->conn = null; 255 256 if (!$this->externalConnection) { 257 $conn->close(); 258 self::$pool[] = $conn; 259 } 260 261 if ($this->log) { 262 $log = $this->log; 263 $this->log = null; 264 265 $conn = $log->establishConnection('w'); 266 queryfx( 267 $conn, 268 'UPDATE %T SET lockReleased = UNIX_TIMESTAMP() WHERE id = %d', 269 $log->getTableName(), 270 $log->getID()); 271 } 272 } 273 274 private function shouldLogLock() { 275 if ($this->disableLogging) { 276 return false; 277 } 278 279 $policy = id(new PhabricatorDaemonLockLogGarbageCollector()) 280 ->getRetentionPolicy(); 281 if (!$policy) { 282 return false; 283 } 284 285 return true; 286 } 287 288 private function newLockContext() { 289 $context = array( 290 'pid' => getmypid(), 291 'host' => php_uname('n'), 292 'sapi' => php_sapi_name(), 293 ); 294 295 global $argv; 296 if ($argv) { 297 $context['argv'] = $argv; 298 } 299 300 $access_log = null; 301 302 // TODO: There's currently no cohesive way to get the parameterized access 303 // log for the current request across different request types. Web requests 304 // have an "AccessLog", SSH requests have an "SSHLog", and other processes 305 // (like scripts) have no log. But there's no method to say "give me any 306 // log you've got". For now, just test if we have a web request and use the 307 // "AccessLog" if we do, since that's the only one we actually read any 308 // parameters from. 309 310 // NOTE: "PhabricatorStartup" is only available from web requests, not 311 // from CLI scripts. 312 if (class_exists('PhabricatorStartup', false)) { 313 $access_log = PhabricatorAccessLog::getLog(); 314 } 315 316 if ($access_log) { 317 $controller = $access_log->getData('C'); 318 if ($controller) { 319 $context['controller'] = $controller; 320 } 321 322 $method = $access_log->getData('m'); 323 if ($method) { 324 $context['method'] = $method; 325 } 326 } 327 328 return $context; 329 } 330 331 private function newHint($lock_name, $wait) { 332 if (!$this->shouldLogLock()) { 333 return pht( 334 'Enable the lock log for more detailed information about '. 335 'which process is holding this lock.'); 336 } 337 338 $now = PhabricatorTime::getNow(); 339 340 // First, look for recent logs. If other processes have been acquiring and 341 // releasing this lock while we've been waiting, this is more likely to be 342 // a contention/throughput issue than an issue with something hung while 343 // holding the lock. 344 $limit = 100; 345 $logs = id(new PhabricatorDaemonLockLog())->loadAllWhere( 346 'lockName = %s AND dateCreated >= %d ORDER BY id ASC LIMIT %d', 347 $lock_name, 348 ($now - $wait), 349 $limit); 350 351 if ($logs) { 352 if (count($logs) === $limit) { 353 return pht( 354 'During the last %s second(s) spent waiting for the lock, more '. 355 'than %s other process(es) acquired it, so this is likely a '. 356 'bottleneck. Use "bin/lock log --name %s" to review log activity.', 357 new PhutilNumber($wait), 358 new PhutilNumber($limit), 359 $lock_name); 360 } else { 361 return pht( 362 'During the last %s second(s) spent waiting for the lock, %s '. 363 'other process(es) acquired it, so this is likely a '. 364 'bottleneck. Use "bin/lock log --name %s" to review log activity.', 365 new PhutilNumber($wait), 366 phutil_count($logs), 367 $lock_name); 368 } 369 } 370 371 $last_log = id(new PhabricatorDaemonLockLog())->loadOneWhere( 372 'lockName = %s ORDER BY id DESC LIMIT 1', 373 $lock_name); 374 375 if ($last_log) { 376 $info = array(); 377 378 $acquired = $last_log->getDateCreated(); 379 $context = $last_log->getLockContext(); 380 381 $process_info = array(); 382 383 $pid = idx($context, 'pid'); 384 if ($pid) { 385 $process_info[] = 'pid='.$pid; 386 } 387 388 $host = idx($context, 'host'); 389 if ($host) { 390 $process_info[] = 'host='.$host; 391 } 392 393 $sapi = idx($context, 'sapi'); 394 if ($sapi) { 395 $process_info[] = 'sapi='.$sapi; 396 } 397 398 $argv = idx($context, 'argv'); 399 if ($argv) { 400 $process_info[] = 'argv='.(string)csprintf('%LR', $argv); 401 } 402 403 $controller = idx($context, 'controller'); 404 if ($controller) { 405 $process_info[] = 'controller='.$controller; 406 } 407 408 $method = idx($context, 'method'); 409 if ($method) { 410 $process_info[] = 'method='.$method; 411 } 412 413 $process_info = implode(', ', $process_info); 414 415 $info[] = pht( 416 'This lock was most recently acquired by a process (%s) '. 417 '%s second(s) ago.', 418 $process_info, 419 new PhutilNumber($now - $acquired)); 420 421 $released = $last_log->getLockReleased(); 422 if ($released) { 423 $info[] = pht( 424 'This lock was released %s second(s) ago.', 425 new PhutilNumber($now - $released)); 426 } else { 427 $info[] = pht('There is no record of this lock being released.'); 428 } 429 430 return implode(' ', $info); 431 } 432 433 return pht( 434 'Found no records of processes acquiring or releasing this lock.'); 435 } 436 437}