Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/infrastructure/util/PhabricatorGlobalLock.php
12241 views
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
*/
29
final 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
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
}
438
439