Reverted r108743 per CR comment. This should at least be discussed first.
[lhc/web/wiklou.git] / maintenance / locking / LockServerDaemon.php
1 <?php
2
3 if ( php_sapi_name() !== 'cli' ) {
4 die( "This is not a valid entry point.\n" );
5 }
6 error_reporting( E_ALL );
7
8 // Run the server...
9 set_time_limit( 0 );
10 LockServerDaemon::init(
11 getopt( '', array(
12 'address:', 'port:', 'authKey:',
13 'connTimeout::', 'lockTimeout::', 'maxClients::', 'maxBacklog::', 'maxLocks::',
14 ) )
15 )->main();
16
17 /**
18 * Simple lock server daemon that accepts lock/unlock requests.
19 * This should not require MediaWiki setup or PHP files.
20 */
21 class LockServerDaemon {
22 /** @var resource */
23 protected $sock; // socket to listen/accept on
24 /** @var Array */
25 protected $shLocks = array(); // (key => session => 1)
26 /** @var Array */
27 protected $exLocks = array(); // (key => session)
28 /** @var Array */
29 protected $sessions = array(); // (session => resource)
30 /** @var Array */
31 protected $deadSessions = array(); // (session => UNIX timestamp)
32
33 /** @var Array */
34 protected $sessionIndexSh = array(); // (session => key => 1)
35 /** @var Array */
36 protected $sessionIndexEx = array(); // (session => key => 1)
37
38 protected $address; // string (IP/hostname)
39 protected $port; // integer
40 protected $authKey; // string key
41 protected $connTimeout; // array ( 'sec' => integer, 'usec' => integer )
42 protected $lockTimeout; // integer number of seconds
43 protected $maxLocks; // integer
44 protected $maxClients; // integer
45 protected $maxBacklog; // integer
46
47 protected $startTime; // integer UNIX timestamp
48 protected $lockCount = 0; // integer
49 protected $ticks = 0; // integer counter
50
51 protected static $instance = null;
52
53 /**
54 * @params $config Array
55 * @return LockServerDaemon
56 */
57 public static function init( array $config ) {
58 if ( self::$instance ) {
59 throw new Exception( 'LockServer already initialized.' );
60 }
61 foreach ( array( 'address', 'port', 'authKey' ) as $par ) {
62 if ( !isset( $config[$par] ) ) {
63 die( "Usage: php LockServerDaemon.php " .
64 "--address <address> --port <port> --authkey <key> " .
65 "[--connTimeout <seconds>] [--lockTimeout <seconds>] " .
66 "[--maxLocks <integer>] [--maxClients <integer>] [--maxBacklog <integer>]"
67 );
68 }
69 }
70 self::$instance = new self( $config );
71 return self::$instance;
72 }
73
74 /**
75 * @params $config Array
76 */
77 protected function __construct( array $config ) {
78 // Required parameters...
79 $this->address = $config['address'];
80 $this->port = $config['port'];
81 $this->authKey = $config['authKey'];
82 // Parameters with defaults...
83 $connTimeout = isset( $config['connTimeout'] )
84 ? $config['connTimeout']
85 : 1.5;
86 $this->connTimeout = array(
87 'sec' => floor( $connTimeout ),
88 'usec' => floor( ( $connTimeout - floor( $connTimeout ) ) * 1e6 )
89 );
90 $this->lockTimeout = isset( $config['lockTimeout'] )
91 ? $config['lockTimeout']
92 : 60;
93 $this->maxLocks = isset( $config['maxLocks'] )
94 ? $config['maxLocks']
95 : 5000;
96 $this->maxClients = isset( $config['maxClients'] )
97 ? $config['maxClients']
98 : 1000; // less than default FD_SETSIZE
99 $this->maxBacklog = isset( $config['maxBacklog'] )
100 ? $config['maxBacklog']
101 : 10;
102 }
103
104 /**
105 * @return void
106 */
107 protected function setupSocket() {
108 if ( !function_exists( 'socket_create' ) ) {
109 throw new Exception( "PHP sockets extension missing from PHP CLI mode." );
110 }
111 $sock = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
112 if ( $sock === false ) {
113 throw new Exception( "socket_create(): " . socket_strerror( socket_last_error() ) );
114 }
115 socket_set_option( $sock, SOL_SOCKET, SO_REUSEADDR, 1 ); // bypass 2MLS
116 if ( socket_bind( $sock, $this->address, $this->port ) === false ) {
117 throw new Exception( "socket_bind(): " .
118 socket_strerror( socket_last_error( $sock ) ) );
119 } elseif ( socket_listen( $sock, $this->maxBacklog ) === false ) {
120 throw new Exception( "socket_listen(): " .
121 socket_strerror( socket_last_error( $sock ) ) );
122 }
123 $this->sock = $sock;
124
125 $this->startTime = time();
126 }
127
128 /**
129 * @return void
130 */
131 public function main() {
132 // Setup socket and start listing
133 $this->setupSocket();
134 // Create a list of all the clients that will be connected to us.
135 $clients = array( $this->sock ); // start off with listening socket
136 do {
137 // Create a copy, so $clients doesn't get modified by socket_select()
138 $read = $clients; // clients-with-data
139 // Get a list of all the clients that have data to be read from
140 $changed = socket_select( $read, $write = NULL, $except = NULL, NULL );
141 if ( $changed === false ) {
142 trigger_error( 'socket_listen(): ' . socket_strerror( socket_last_error() ) );
143 continue;
144 } elseif ( $changed < 1 ) {
145 continue; // wait
146 }
147 // Check if there is a client trying to connect...
148 if ( in_array( $this->sock, $read ) && count( $clients ) < $this->maxClients ) {
149 // Accept the new client...
150 $newsock = socket_accept( $this->sock );
151 socket_set_option( $newsock, SOL_SOCKET, SO_RCVTIMEO, $this->connTimeout );
152 socket_set_option( $newsock, SOL_SOCKET, SO_SNDTIMEO, $this->connTimeout );
153 $clients[] = $newsock;
154 // Remove the listening socket from the clients-with-data array...
155 $key = array_search( $this->sock, $read );
156 unset( $read[$key] );
157 }
158 // Loop through all the clients that have data to read...
159 foreach ( $read as $read_sock ) {
160 // Read until newline or 65535 bytes are recieved.
161 // socket_read show errors when the client is disconnected.
162 $data = @socket_read( $read_sock, 65535, PHP_NORMAL_READ );
163 // Check if the client is disconnected
164 if ( $data === false ) {
165 // Remove client from $clients list
166 $key = array_search( $read_sock, $clients );
167 unset( $clients[$key] );
168 // Remove socket's session from tracking (if it exists)
169 $session = array_search( $read_sock, $this->sessions );
170 if ( $session !== false ) {
171 unset( $this->sessions[$session] );
172 // Record recently killed sessions that still have locks
173 if ( isset( $this->sessionIndexSh[$session] )
174 || isset( $this->sessionIndexEx[$session] ) )
175 {
176 $this->deadSessions[$session] = time();
177 }
178 }
179 } else {
180 // Perform the requested command...
181 $response = $this->doCommand( trim( $data ), $read_sock );
182 // Send the response to the client...
183 if ( socket_write( $read_sock, "$response\n" ) === false ) {
184 trigger_error( 'socket_write(): ' .
185 socket_strerror( socket_last_error( $read_sock ) ) );
186 }
187 }
188 }
189 // Prune dead locks every 10 socket events...
190 if ( ++$this->ticks >= 9 ) {
191 $this->ticks = 0;
192 $this->purgeExpiredLocks();
193 }
194 } while ( true );
195 }
196
197 /**
198 * @param $data string
199 * @param $sourceSock resource
200 * @return string
201 */
202 protected function doCommand( $data, $sourceSock ) {
203 $cmdArr = $this->getCommand( $data );
204 if ( is_string( $cmdArr ) ) {
205 return $cmdArr; // error
206 }
207 list( $function, $session, $type, $resources ) = $cmdArr;
208 // On first command, track the session => sock correspondence
209 if ( !isset( $this->sessions[$session] ) ) {
210 $this->sessions[$session] = $sourceSock;
211 }
212 if ( $function === 'ACQUIRE' ) {
213 return $this->lock( $session, $type, $resources );
214 } elseif ( $function === 'RELEASE' ) {
215 return $this->unlock( $session, $type, $resources );
216 } elseif ( $function === 'RELEASE_ALL' ) {
217 return $this->release( $session );
218 } elseif ( $function === 'STAT' ) {
219 return $this->stat();
220 }
221 return 'INTERNAL_ERROR';
222 }
223
224 /**
225 * @param $data string
226 * @return Array
227 */
228 protected function getCommand( $data ) {
229 $m = explode( ':', $data ); // <session, key, command, type, values>
230 if ( count( $m ) == 5 ) {
231 list( $session, $key, $command, $type, $values ) = $m;
232 if ( sha1( $session . $command . $type . $values . $this->authKey ) !== $key ) {
233 return 'BAD_KEY';
234 } elseif ( strlen( $session ) !== 31 ) {
235 return 'BAD_SESSION';
236 }
237 $values = explode( '|', $values );
238 if ( $command === 'ACQUIRE' ) {
239 $needsLockArgs = true;
240 } elseif ( $command === 'RELEASE' ) {
241 $needsLockArgs = true;
242 } elseif ( $command === 'RELEASE_ALL' ) {
243 $needsLockArgs = false;
244 } elseif ( $command === 'STAT' ) {
245 $needsLockArgs = false;
246 } else {
247 return 'BAD_COMMAND';
248 }
249 if ( $needsLockArgs ) {
250 if ( $type !== 'SH' && $type !== 'EX' ) {
251 return 'BAD_TYPE';
252 }
253 foreach ( $values as $value ) {
254 if ( strlen( $value ) !== 31 ) {
255 return 'BAD_FORMAT';
256 }
257 }
258 }
259 return array( $command, $session, $type, $values );
260 }
261 return 'BAD_FORMAT';
262 }
263
264 /**
265 * @param $session string
266 * @param $type string
267 * @param $keys Array
268 * @return string
269 */
270 protected function lock( $session, $type, $keys ) {
271 if ( $this->lockCount >= $this->maxLocks ) {
272 return 'TOO_MANY_LOCKS';
273 }
274 if ( $type === 'SH' ) {
275 // Check if any keys are already write-locked...
276 foreach ( $keys as $key ) {
277 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] !== $session ) {
278 return 'CANT_ACQUIRE';
279 }
280 }
281 // Acquire the read-locks...
282 foreach ( $keys as $key ) {
283 $this->set_sh_lock( $key, $session );
284 }
285 return 'ACQUIRED';
286 } elseif ( $type === 'EX' ) {
287 // Check if any keys are already read-locked or write-locked...
288 foreach ( $keys as $key ) {
289 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] !== $session ) {
290 return 'CANT_ACQUIRE';
291 }
292 if ( isset( $this->shLocks[$key] ) ) {
293 foreach ( $this->shLocks[$key] as $otherSession => $x ) {
294 if ( $otherSession !== $session ) {
295 return 'CANT_ACQUIRE';
296 }
297 }
298 }
299 }
300 // Acquire the write-locks...
301 foreach ( $keys as $key ) {
302 $this->set_ex_lock( $key, $session );
303 }
304 return 'ACQUIRED';
305 }
306 return 'INTERNAL_ERROR';
307 }
308
309 /**
310 * @param $session string
311 * @param $type string
312 * @param $keys Array
313 * @return string
314 */
315 protected function unlock( $session, $type, $keys ) {
316 if ( $type === 'SH' ) {
317 foreach ( $keys as $key ) {
318 $this->unset_sh_lock( $key, $session );
319 }
320 return 'RELEASED';
321 } elseif ( $type === 'EX' ) {
322 foreach ( $keys as $key ) {
323 $this->unset_ex_lock( $key, $session );
324 }
325 return 'RELEASED';
326 }
327 return 'INTERNAL_ERROR';
328 }
329
330 /**
331 * @param $session string
332 * @return string
333 */
334 protected function release( $session ) {
335 if ( isset( $this->sessionIndexSh[$session] ) ) {
336 foreach ( $this->sessionIndexSh[$session] as $key => $x ) {
337 $this->unset_sh_lock( $key, $session );
338 }
339 }
340 if ( isset( $this->sessionIndexEx[$session] ) ) {
341 foreach ( $this->sessionIndexEx[$session] as $key => $x ) {
342 $this->unset_ex_lock( $key, $session );
343 }
344 }
345 return 'RELEASED_ALL';
346 }
347
348 /**
349 * @return string
350 */
351 protected function stat() {
352 return ( time() - $this->startTime ) . ':' . memory_get_usage();
353 }
354
355 /**
356 * Clear locks for sessions that have been dead for a while
357 *
358 * @return void
359 */
360 protected function purgeExpiredLocks() {
361 $now = time();
362 foreach ( $this->deadSessions as $session => $timestamp ) {
363 if ( ( $now - $timestamp ) > $this->lockTimeout ) {
364 $this->release( $session );
365 unset( $this->deadSessions[$session] );
366 }
367 }
368 }
369
370 /**
371 * @param $key string
372 * @param $session string
373 * @return void
374 */
375 protected function set_sh_lock( $key, $session ) {
376 if ( !isset( $this->shLocks[$key][$session] ) ) {
377 $this->shLocks[$key][$session] = 1;
378 $this->sessionIndexSh[$session][$key] = 1;
379 ++$this->lockCount; // we are adding a lock
380 }
381 }
382
383 /**
384 * @param $key string
385 * @param $session string
386 * @return void
387 */
388 protected function set_ex_lock( $key, $session ) {
389 if ( !isset( $this->exLocks[$key][$session] ) ) {
390 $this->exLocks[$key] = $session;
391 $this->sessionIndexEx[$session][$key] = 1;
392 ++$this->lockCount; // we are adding a lock
393 }
394 }
395
396 /**
397 * @param $key string
398 * @param $session string
399 * @return void
400 */
401 protected function unset_sh_lock( $key, $session ) {
402 if ( isset( $this->shLocks[$key][$session] ) ) {
403 unset( $this->shLocks[$key][$session] );
404 if ( !count( $this->shLocks[$key] ) ) {
405 unset( $this->shLocks[$key] );
406 }
407 unset( $this->sessionIndexSh[$session][$key] );
408 if ( !count( $this->sessionIndexSh[$session] ) ) {
409 unset( $this->sessionIndexSh[$session] );
410 }
411 --$this->lockCount;
412 }
413 }
414
415 /**
416 * @param $key string
417 * @param $session string
418 * @return void
419 */
420 protected function unset_ex_lock( $key, $session ) {
421 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] === $session ) {
422 unset( $this->exLocks[$key] );
423 unset( $this->sessionIndexEx[$session][$key] );
424 if ( !count( $this->sessionIndexEx[$session] ) ) {
425 unset( $this->sessionIndexEx[$session] );
426 }
427 --$this->lockCount;
428 }
429 }
430 }