Merge "Update formatting of file backend classes"
[lhc/web/wiklou.git] / maintenance / locking / LockServerDaemon.php
1 <?php
2 /**
3 * Simple lock server daemon that accepts lock/unlock requests.
4 *
5 * This code should not require MediaWiki setup or PHP files.
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 * @ingroup LockManager Maintenance
24 */
25
26 if ( PHP_SAPI !== 'cli' ) {
27 die( "This is not a valid entry point.\n" );
28 }
29 error_reporting( E_ALL );
30
31 // Run the server...
32 set_time_limit( 0 );
33 LockServerDaemon::init(
34 getopt( '', array(
35 'address:', 'port:', 'authKey:',
36 'lockTimeout::', 'maxClients::', 'maxBacklog::', 'maxLocks::',
37 ) )
38 )->main();
39
40 /**
41 * Simple lock server daemon that accepts lock/unlock requests
42 *
43 * @ingroup LockManager Maintenance
44 */
45 class LockServerDaemon {
46 /** @var resource */
47 protected $sock; // socket to listen/accept on
48 /** @var Array */
49 protected $sessions = array(); // (session => resource)
50 /** @var Array */
51 protected $deadSessions = array(); // (session => UNIX timestamp)
52
53 /** @var LockHolder */
54 protected $lockHolder;
55
56 protected $address; // string IP address
57 protected $port; // integer
58 protected $authKey; // string key
59 protected $lockTimeout; // integer number of seconds
60 protected $maxBacklog; // integer
61 protected $maxClients; // integer
62
63 protected $startTime; // integer UNIX timestamp
64 protected $ticks = 0; // integer counter
65
66 /* @var LockServerDaemon */
67 protected static $instance = null;
68
69 /**
70 * @params $config Array
71 * @param array $config
72 * @throws Exception
73 * @return LockServerDaemon
74 */
75 public static function init( array $config ) {
76 if ( self::$instance ) {
77 throw new Exception( 'LockServer already initialized.' );
78 }
79 foreach ( array( 'address', 'port', 'authKey' ) as $par ) {
80 if ( !isset( $config[$par] ) ) {
81 die( "Usage: php LockServerDaemon.php " .
82 "--address <address> --port <port> --authKey <key> " .
83 "[--lockTimeout <seconds>] " .
84 "[--maxLocks <integer>] [--maxClients <integer>] [--maxBacklog <integer>]\n"
85 );
86 }
87 }
88 self::$instance = new self( $config );
89 return self::$instance;
90 }
91
92 /**
93 * @params $config Array
94 */
95 protected function __construct( array $config ) {
96 // Required parameters...
97 $this->address = $config['address'];
98 $this->port = $config['port'];
99 $this->authKey = $config['authKey'];
100 // Parameters with defaults...
101 $this->lockTimeout = isset( $config['lockTimeout'] )
102 ? (int)$config['lockTimeout']
103 : 60;
104 $this->maxClients = isset( $config['maxClients'] )
105 ? (int)$config['maxClients']
106 : 1000; // less than default FD_SETSIZE
107 $this->maxBacklog = isset( $config['maxBacklog'] )
108 ? (int)$config['maxBacklog']
109 : 100;
110 $maxLocks = isset( $config['maxLocks'] )
111 ? (int)$config['maxLocks']
112 : 10000;
113
114 $this->lockHolder = new LockHolder( $maxLocks );
115 }
116
117 /**
118 * @throws Exception
119 * @return void
120 */
121 protected function setupServerSocket() {
122 if ( !function_exists( 'socket_create' ) ) {
123 throw new Exception( "PHP sockets extension missing from PHP CLI mode." );
124 }
125 $sock = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
126 if ( $sock === false ) {
127 throw new Exception( "socket_create(): " . socket_strerror( socket_last_error() ) );
128 }
129 socket_set_option( $sock, SOL_SOCKET, SO_REUSEADDR, 1 ); // bypass 2MLS
130 socket_set_nonblock( $sock ); // don't block on accept()
131 if ( socket_bind( $sock, $this->address, $this->port ) === false ) {
132 throw new Exception( "socket_bind(): " .
133 socket_strerror( socket_last_error( $sock ) ) );
134 } elseif ( socket_listen( $sock, $this->maxBacklog ) === false ) {
135 throw new Exception( "socket_listen(): " .
136 socket_strerror( socket_last_error( $sock ) ) );
137 }
138 $this->sock = $sock;
139 $this->startTime = time();
140 }
141
142 /**
143 * Entry-point function that listens to the server socket, accepts
144 * new clients, and recieves/responds to requests to lock resources.
145 */
146 public function main() {
147 $this->setupServerSocket(); // setup listening socket
148 $socketArray = new SocketArray(); // sockets being serviced
149 $socketArray->addSocket( $this->sock ); // add listening socket
150 do {
151 list( $read, $write ) = $socketArray->socketsForSelect();
152 if ( socket_select( $read, $write, $except = null, null ) < 1 ) {
153 continue; // wait
154 }
155 // Check if there is a client trying to connect...
156 if ( in_array( $this->sock, $read ) && $socketArray->size() < $this->maxClients ) {
157 $newSock = socket_accept( $this->sock );
158 if ( $newSock ) {
159 socket_set_option( $newSock, SOL_SOCKET, SO_KEEPALIVE, 1 );
160 socket_set_nonblock( $newSock ); // don't block on read()/write()
161 $socketArray->addSocket( $newSock );
162 }
163 }
164 // Loop through all the clients that have data to read...
165 foreach ( $read as $read_sock ) {
166 if ( $read_sock === $this->sock ) {
167 continue; // skip listening socket
168 }
169 // Avoids PHP_NORMAL_READ per https://bugs.php.net/bug.php?id=33471
170 $data = socket_read( $read_sock, 65535 );
171 // Check if the client is disconnected
172 if ( $data === false || $data === '' ) {
173 $socketArray->closeSocket( $read_sock );
174 $this->recordDeadSocket( $read_sock ); // remove session
175 // Check if we reached the end of a message
176 } elseif ( substr( $data, -1 ) === "\n" ) {
177 // Newline is the last char (given ping-pong message usage)
178 $cmd = $socketArray->readRcvBuffer( $read_sock ) . $data;
179 // Perform the requested command...
180 $response = $this->doCommand( rtrim( $cmd ), $read_sock );
181 // Send the response to the client...
182 $socketArray->appendSndBuffer( $read_sock, $response . "\n" );
183 // Otherwise, we just have more message data to append
184 } elseif ( !$socketArray->appendRcvBuffer( $read_sock, $data ) ) {
185 $socketArray->closeSocket( $read_sock ); // too big
186 $this->recordDeadSocket( $read_sock ); // remove session
187 }
188 }
189 // Loop through all the clients that have data to write...
190 foreach ( $write as $write_sock ) {
191 $bytes = socket_write( $write_sock, $socketArray->readSndBuffer( $write_sock ) );
192 // Check if the client is disconnected
193 if ( $bytes === false ) {
194 $socketArray->closeSocket( $write_sock );
195 $this->recordDeadSocket( $write_sock ); // remove session
196 // Otherwise, truncate these bytes from the start of the write buffer
197 } else {
198 $socketArray->consumeSndBuffer( $write_sock, $bytes );
199 }
200 }
201 // Prune dead locks every few socket events...
202 if ( ++$this->ticks >= 9 ) {
203 $this->ticks = 0;
204 $this->purgeExpiredLocks();
205 }
206 } while ( true );
207 }
208
209 /**
210 * @param $data string
211 * @param $sourceSock resource
212 * @return string
213 */
214 protected function doCommand( $data, $sourceSock ) {
215 $cmdArr = $this->getCommand( $data );
216 if ( is_string( $cmdArr ) ) {
217 return $cmdArr; // error
218 }
219 list( $function, $session, $type, $resources ) = $cmdArr;
220 // On first command, track the session => sock correspondence
221 if ( !isset( $this->sessions[$session] ) ) {
222 $this->sessions[$session] = $sourceSock;
223 unset( $this->deadSessions[$session] ); // renew if dead
224 }
225 if ( $function === 'ACQUIRE' ) {
226 return $this->lockHolder->lock( $session, $type, $resources );
227 } elseif ( $function === 'RELEASE' ) {
228 return $this->lockHolder->unlock( $session, $type, $resources );
229 } elseif ( $function === 'RELEASE_ALL' ) {
230 return $this->lockHolder->release( $session );
231 } elseif ( $function === 'STAT' ) {
232 return $this->stat();
233 }
234 return 'INTERNAL_ERROR';
235 }
236
237 /**
238 * @param $data string
239 * @return Array
240 */
241 protected function getCommand( $data ) {
242 $m = explode( ':', $data ); // <session, key, command, type, values>
243 if ( count( $m ) == 5 ) {
244 list( $session, $key, $command, $type, $values ) = $m;
245 $goodKey = hash_hmac( 'sha1',
246 "{$session}\n{$command}\n{$type}\n{$values}", $this->authKey );
247 if ( $goodKey !== $key ) {
248 return 'BAD_KEY';
249 } elseif ( strlen( $session ) !== 32 ) {
250 return 'BAD_SESSION';
251 }
252 $values = explode( '|', $values );
253 if ( $command === 'ACQUIRE' ) {
254 $needsLockArgs = true;
255 } elseif ( $command === 'RELEASE' ) {
256 $needsLockArgs = true;
257 } elseif ( $command === 'RELEASE_ALL' ) {
258 $needsLockArgs = false;
259 } elseif ( $command === 'STAT' ) {
260 $needsLockArgs = false;
261 } else {
262 return 'BAD_COMMAND';
263 }
264 if ( $needsLockArgs ) {
265 if ( $type !== 'SH' && $type !== 'EX' ) {
266 return 'BAD_TYPE';
267 }
268 foreach ( $values as $value ) {
269 if ( strlen( $value ) !== 31 ) {
270 return 'BAD_FORMAT';
271 }
272 }
273 }
274 return array( $command, $session, $type, $values );
275 }
276 return 'BAD_FORMAT';
277 }
278
279 /**
280 * Remove a socket's corresponding session from tracking and
281 * store it in the dead session tracking if it still has locks.
282 *
283 * @param $socket resource
284 * @return bool
285 */
286 protected function recordDeadSocket( $socket ) {
287 $session = array_search( $socket, $this->sessions );
288 if ( $session !== false ) {
289 unset( $this->sessions[$session] );
290 // Record recently killed sessions that still have locks
291 if ( $this->lockHolder->sessionHasLocks( $session ) ) {
292 $this->deadSessions[$session] = time();
293 }
294 return true;
295 }
296 return false;
297 }
298
299 /**
300 * Clear locks for sessions that have been dead for a while
301 *
302 * @return integer Number of sessions purged
303 */
304 protected function purgeExpiredLocks() {
305 $count = 0;
306 $now = time();
307 foreach ( $this->deadSessions as $session => $timestamp ) {
308 if ( ( $now - $timestamp ) > $this->lockTimeout ) {
309 $this->lockHolder->release( $session );
310 unset( $this->deadSessions[$session] );
311 ++$count;
312 }
313 }
314 return $count;
315 }
316
317 /**
318 * Get the current timestamp and memory usage
319 *
320 * @return string
321 */
322 protected function stat() {
323 return ( time() - $this->startTime ) . ':' . memory_get_usage();
324 }
325 }
326
327 /**
328 * LockServerDaemon helper class that keeps track socket states
329 */
330 class SocketArray {
331 /* @var Array */
332 protected $clients = array(); // array of client sockets
333 /* @var Array */
334 protected $rBuffers = array(); // corresponding socket read buffers
335 /* @var Array */
336 protected $wBuffers = array(); // corresponding socket write buffers
337
338 const BUFFER_SIZE = 65535;
339
340 /**
341 * @return Array (list of sockets to read, list of sockets to write)
342 */
343 public function socketsForSelect() {
344 $rSockets = array();
345 $wSockets = array();
346 foreach ( $this->clients as $key => $socket ) {
347 if ( $this->wBuffers[$key] !== '' ) {
348 $wSockets[] = $socket; // wait for writing to unblock
349 } else {
350 $rSockets[] = $socket; // wait for reading to unblock
351 }
352 }
353 return array( $rSockets, $wSockets );
354 }
355
356 /**
357 * @return integer Number of client sockets
358 */
359 public function size() {
360 return count( $this->clients );
361 }
362
363 /**
364 * @param $sock resource
365 * @return bool
366 */
367 public function addSocket( $sock ) {
368 $this->clients[] = $sock;
369 $this->rBuffers[] = '';
370 $this->wBuffers[] = '';
371 return true;
372 }
373
374 /**
375 * @param $sock resource
376 * @return bool
377 */
378 public function closeSocket( $sock ) {
379 $key = array_search( $sock, $this->clients );
380 if ( $key === false ) {
381 return false;
382 }
383 socket_close( $sock );
384 unset( $this->clients[$key] );
385 unset( $this->rBuffers[$key] );
386 unset( $this->wBuffers[$key] );
387 return true;
388 }
389
390 /**
391 * @param $sock resource
392 * @param $data string
393 * @return bool
394 */
395 public function appendRcvBuffer( $sock, $data ) {
396 $key = array_search( $sock, $this->clients );
397 if ( $key === false ) {
398 return false;
399 } elseif ( ( strlen( $this->rBuffers[$key] ) + strlen( $data ) ) > self::BUFFER_SIZE ) {
400 return false;
401 }
402 $this->rBuffers[$key] .= $data;
403 return true;
404 }
405
406 /**
407 * @param $sock resource
408 * @return string|bool
409 */
410 public function readRcvBuffer( $sock ) {
411 $key = array_search( $sock, $this->clients );
412 if ( $key === false ) {
413 return false;
414 }
415 $data = $this->rBuffers[$key];
416 $this->rBuffers[$key] = ''; // consume data
417 return $data;
418 }
419
420 /**
421 * @param $sock resource
422 * @param $data string
423 * @return bool
424 */
425 public function appendSndBuffer( $sock, $data ) {
426 $key = array_search( $sock, $this->clients );
427 if ( $key === false ) {
428 return false;
429 } elseif ( ( strlen( $this->wBuffers[$key] ) + strlen( $data ) ) > self::BUFFER_SIZE ) {
430 return false;
431 }
432 $this->wBuffers[$key] .= $data;
433 return true;
434 }
435
436 /**
437 * @param $sock resource
438 * @return bool
439 */
440 public function readSndBuffer( $sock ) {
441 $key = array_search( $sock, $this->clients );
442 if ( $key === false ) {
443 return false;
444 }
445 return $this->wBuffers[$key];
446 }
447
448 /**
449 * @param $sock resource
450 * @param $bytes integer
451 * @return bool
452 */
453 public function consumeSndBuffer( $sock, $bytes ) {
454 $key = array_search( $sock, $this->clients );
455 if ( $key === false ) {
456 return false;
457 }
458 $this->wBuffers[$key] = (string)substr( $this->wBuffers[$key], $bytes );
459 return true;
460 }
461 }
462
463 /**
464 * LockServerDaemon helper class that keeps track of the locks
465 */
466 class LockHolder {
467 /** @var Array */
468 protected $shLocks = array(); // (key => session => 1)
469 /** @var Array */
470 protected $exLocks = array(); // (key => session)
471
472 /** @var Array */
473 protected $sessionIndexSh = array(); // (session => key => 1)
474 /** @var Array */
475 protected $sessionIndexEx = array(); // (session => key => 1)
476 protected $lockCount = 0; // integer
477
478 protected $maxLocks; // integer
479
480 /**
481 * @params $maxLocks integer Maximum number of locks to allow
482 */
483 public function __construct( $maxLocks ) {
484 $this->maxLocks = $maxLocks;
485 }
486
487 /**
488 * @param $session string
489 * @return bool
490 */
491 public function sessionHasLocks( $session ) {
492 return isset( $this->sessionIndexSh[$session] )
493 || isset( $this->sessionIndexEx[$session] );
494 }
495
496 /**
497 * @param $session string
498 * @param $type string
499 * @param $keys Array
500 * @return string
501 */
502 public function lock( $session, $type, array $keys ) {
503 if ( ( $this->lockCount + count( $keys ) ) > $this->maxLocks ) {
504 return 'TOO_MANY_LOCKS';
505 }
506 if ( $type === 'SH' ) {
507 // Check if any keys are already write-locked...
508 foreach ( $keys as $key ) {
509 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] !== $session ) {
510 return 'CANT_ACQUIRE';
511 }
512 }
513 // Acquire the read-locks...
514 foreach ( $keys as $key ) {
515 $this->set_sh_lock( $key, $session );
516 }
517 return 'ACQUIRED';
518 } elseif ( $type === 'EX' ) {
519 // Check if any keys are already read-locked or write-locked...
520 foreach ( $keys as $key ) {
521 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] !== $session ) {
522 return 'CANT_ACQUIRE';
523 }
524 if ( isset( $this->shLocks[$key] ) ) {
525 foreach ( $this->shLocks[$key] as $otherSession => $x ) {
526 if ( $otherSession !== $session ) {
527 return 'CANT_ACQUIRE';
528 }
529 }
530 }
531 }
532 // Acquire the write-locks...
533 foreach ( $keys as $key ) {
534 $this->set_ex_lock( $key, $session );
535 }
536 return 'ACQUIRED';
537 }
538 return 'INTERNAL_ERROR';
539 }
540
541 /**
542 * @param $session string
543 * @param $type string
544 * @param $keys Array
545 * @return string
546 */
547 public function unlock( $session, $type, array $keys ) {
548 if ( $type === 'SH' ) {
549 foreach ( $keys as $key ) {
550 $this->unset_sh_lock( $key, $session );
551 }
552 return 'RELEASED';
553 } elseif ( $type === 'EX' ) {
554 foreach ( $keys as $key ) {
555 $this->unset_ex_lock( $key, $session );
556 }
557 return 'RELEASED';
558 }
559 return 'INTERNAL_ERROR';
560 }
561
562 /**
563 * @param $session string
564 * @return string
565 */
566 public function release( $session ) {
567 if ( isset( $this->sessionIndexSh[$session] ) ) {
568 foreach ( $this->sessionIndexSh[$session] as $key => $x ) {
569 $this->unset_sh_lock( $key, $session );
570 }
571 }
572 if ( isset( $this->sessionIndexEx[$session] ) ) {
573 foreach ( $this->sessionIndexEx[$session] as $key => $x ) {
574 $this->unset_ex_lock( $key, $session );
575 }
576 }
577 return 'RELEASED_ALL';
578 }
579
580 /**
581 * @param $key string
582 * @param $session string
583 * @return void
584 */
585 protected function set_sh_lock( $key, $session ) {
586 if ( !isset( $this->shLocks[$key][$session] ) ) {
587 $this->shLocks[$key][$session] = 1;
588 $this->sessionIndexSh[$session][$key] = 1;
589 ++$this->lockCount; // we are adding a lock
590 }
591 }
592
593 /**
594 * @param $key string
595 * @param $session string
596 * @return void
597 */
598 protected function set_ex_lock( $key, $session ) {
599 if ( !isset( $this->exLocks[$key][$session] ) ) {
600 $this->exLocks[$key] = $session;
601 $this->sessionIndexEx[$session][$key] = 1;
602 ++$this->lockCount; // we are adding a lock
603 }
604 }
605
606 /**
607 * @param $key string
608 * @param $session string
609 * @return void
610 */
611 protected function unset_sh_lock( $key, $session ) {
612 if ( isset( $this->shLocks[$key][$session] ) ) {
613 unset( $this->shLocks[$key][$session] );
614 if ( !count( $this->shLocks[$key] ) ) {
615 unset( $this->shLocks[$key] );
616 }
617 unset( $this->sessionIndexSh[$session][$key] );
618 if ( !count( $this->sessionIndexSh[$session] ) ) {
619 unset( $this->sessionIndexSh[$session] );
620 }
621 --$this->lockCount;
622 }
623 }
624
625 /**
626 * @param $key string
627 * @param $session string
628 * @return void
629 */
630 protected function unset_ex_lock( $key, $session ) {
631 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] === $session ) {
632 unset( $this->exLocks[$key] );
633 unset( $this->sessionIndexEx[$session][$key] );
634 if ( !count( $this->sessionIndexEx[$session] ) ) {
635 unset( $this->sessionIndexEx[$session] );
636 }
637 --$this->lockCount;
638 }
639 }
640 }