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