@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
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}