Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 402
0.00% covered (danger)
0.00%
0 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
MemcachedClient
0.00% covered (danger)
0.00%
0 / 402
0.00% covered (danger)
0.00%
0 / 38
27390
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 serialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unserialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 add
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 decr
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 delete
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 touch
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 disconnect_all
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 enable_compress
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 forget_dead_hosts
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
210
 get_multi
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
210
 incr
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 replace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run_command
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 set
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cas
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_compress_threshold
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_debug
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_servers
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 set_timeout
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 _close_sock
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 _connect_sock
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 _dead_sock
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 _dead_host
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 get_sock
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
132
 _hashfunc
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 _incrdecr
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
 _load_items
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
156
 _set
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
420
 sock_to_host
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 _debugprint
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 _error_log
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 _fwrite
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 _handle_error
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 _fread
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 _fgets
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 _flush_read_buffer
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2// phpcs:ignoreFile -- It's an external lib and it isn't. Let's not bother.
3/**
4 * Memcached client for PHP.
5 *
6 * +---------------------------------------------------------------------------+
7 * | memcached client, PHP                                                     |
8 * +---------------------------------------------------------------------------+
9 * | Copyright (c) 2003 Ryan T. Dean <rtdean@cytherianage.net>                 |
10 * | All rights reserved.                                                      |
11 * |                                                                           |
12 * | Redistribution and use in source and binary forms, with or without        |
13 * | modification, are permitted provided that the following conditions        |
14 * | are met:                                                                  |
15 * |                                                                           |
16 * | 1. Redistributions of source code must retain the above copyright         |
17 * |    notice, this list of conditions and the following disclaimer.          |
18 * | 2. Redistributions in binary form must reproduce the above copyright      |
19 * |    notice, this list of conditions and the following disclaimer in the    |
20 * |    documentation and/or other materials provided with the distribution.   |
21 * |                                                                           |
22 * | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR      |
23 * | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES |
24 * | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.   |
25 * | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,          |
26 * | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT  |
27 * | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
28 * | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY     |
29 * | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT       |
30 * | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF  |
31 * | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.         |
32 * +---------------------------------------------------------------------------+
33 * | Author: Ryan T. Dean <rtdean@cytherianage.net>                            |
34 * | Heavily influenced by the Perl memcached client by Brad Fitzpatrick.      |
35 * |   Permission granted by Brad Fitzpatrick for relicense of ported Perl     |
36 * |   client logic under 2-clause BSD license.                                |
37 * +---------------------------------------------------------------------------+
38 *
39 * @file
40 * $TCAnet$
41 */
42
43/**
44 * This is a PHP client for memcached - a distributed memory cache daemon.
45 *
46 * More information is available at http://www.danga.com/memcached/
47 *
48 * Usage example:
49 *
50 *     $mc = new MemcachedClient(array(
51 *         'servers' => array(
52 *             '127.0.0.1:10000',
53 *             array( '192.0.0.1:10010', 2 ),
54 *             '127.0.0.1:10020'
55 *         ),
56 *         'debug'   => false,
57 *         'compress_threshold' => 10240,
58 *         'persistent' => true
59 *     ));
60 *
61 *     $mc->add( 'key', array( 'some', 'array' ) );
62 *     $mc->replace( 'key', 'some random string' );
63 *     $val = $mc->get( 'key' );
64 *
65 * @author Ryan T. Dean <rtdean@cytherianage.net>
66 * @version 0.1.2
67 */
68
69use Psr\Log\LoggerInterface;
70use Psr\Log\NullLogger;
71use Wikimedia\IPUtils;
72use Wikimedia\ObjectCache\BagOStuff;
73
74/**
75 * memcached client class implemented using (p)fsockopen()
76 *
77 * @author  Ryan T. Dean <rtdean@cytherianage.net>
78 * @ingroup Cache
79 */
80class MemcachedClient {
81    // {{{ properties
82    // {{{ public
83
84    // {{{ constants
85    // {{{ flags
86
87    /**
88     * Flag: indicates data is serialized
89     */
90    const SERIALIZED = 1;
91
92    /**
93     * Flag: indicates data is compressed
94     */
95    const COMPRESSED = 2;
96
97    /**
98     * Flag: indicates data is an integer
99     */
100    const INTVAL = 4;
101
102    // }}}
103
104    /**
105     * Minimum savings to store data compressed
106     */
107    const COMPRESSION_SAVINGS = 0.20;
108
109    // }}}
110
111    /**
112     * Command statistics
113     *
114     * @var array
115     * @access public
116     */
117    public $stats;
118
119    // }}}
120    // {{{ private
121
122    /**
123     * Cached Sockets that are connected
124     *
125     * @var array
126     * @access private
127     */
128    public $_cache_sock;
129
130    /**
131     * Current debug status; 0 - none to 9 - profiling
132     *
133     * @var bool
134     * @access private
135     */
136    public $_debug;
137
138    /**
139     * Dead hosts, assoc array, 'host'=>'unixtime when ok to check again'
140     *
141     * @var array
142     * @access private
143     */
144    public $_host_dead;
145
146    /**
147     * Is compression available?
148     *
149     * @var bool
150     * @access private
151     */
152    public $_have_zlib;
153
154    /**
155     * Do we want to use compression?
156     *
157     * @var bool
158     * @access private
159     */
160    public $_compress_enable;
161
162    /**
163     * At how many bytes should we compress?
164     *
165     * @var int
166     * @access private
167     */
168    public $_compress_threshold;
169
170    /**
171     * Are we using persistent links?
172     *
173     * @var bool
174     * @access private
175     */
176    public $_persistent;
177
178    /**
179     * If only using one server; contains ip:port to connect to
180     *
181     * @var string
182     * @access private
183     */
184    public $_single_sock;
185
186    /**
187     * Array containing ip:port or array(ip:port, weight)
188     *
189     * @var array
190     * @access private
191     */
192    public $_servers;
193
194    /**
195     * Our bit buckets
196     *
197     * @var array
198     * @access private
199     */
200    public $_buckets;
201
202    /**
203     * Total # of bit buckets we have
204     *
205     * @var int
206     * @access private
207     */
208    public $_bucketcount;
209
210    /**
211     * # of total servers we have
212     *
213     * @var int
214     * @access private
215     */
216    public $_active;
217
218    /**
219     * Stream timeout in seconds. Applies for example to fread()
220     *
221     * @var int
222     * @access private
223     */
224    public $_timeout_seconds;
225
226    /**
227     * Stream timeout in microseconds
228     *
229     * @var int
230     * @access private
231     */
232    public $_timeout_microseconds;
233
234    /**
235     * Connect timeout in seconds
236     */
237    public $_connect_timeout;
238
239    /**
240     * Number of connection attempts for each server
241     */
242    public $_connect_attempts;
243
244    /** @var int BagOStuff:ERR_* constant of the last cache command */
245    public $_last_cmd_status = BagOStuff::ERR_NONE;
246
247    /**
248     * @var LoggerInterface
249     */
250    private $_logger;
251
252
253    // }}}
254    // }}}
255    // {{{ methods
256    // {{{ public functions
257    // {{{ memcached()
258
259    /**
260     * Memcache initializer
261     *
262     * @param array $args Associative array of settings
263     */
264    public function __construct( $args ) {
265        $this->set_servers( $args['servers'] ?? array() );
266        $this->_debug = $args['debug'] ?? false;
267        $this->stats = array();
268        $this->_compress_threshold = $args['compress_threshold'] ?? 0;
269        $this->_persistent = $args['persistent'] ?? false;
270        $this->_compress_enable = true;
271        $this->_have_zlib = function_exists( 'gzcompress' );
272
273        $this->_cache_sock = array();
274        $this->_host_dead = array();
275
276        $this->_timeout_seconds = 0;
277        $this->_timeout_microseconds = $args['timeout'] ?? 500_000;
278
279        $this->_connect_timeout = $args['connect_timeout'] ?? 0.1;
280        $this->_connect_attempts = 2;
281
282        $this->_logger = $args['logger'] ?? new NullLogger();
283    }
284
285    // }}}
286
287    /**
288     * @param mixed $value
289     * @return string|integer
290     */
291    public function serialize( $value ) {
292        return serialize( $value );
293    }
294
295    /**
296     * @param string $value
297     * @return mixed
298     */
299    public function unserialize( $value ) {
300        return unserialize( $value );
301    }
302
303    // {{{ add()
304
305    /**
306     * Adds a key/value to the memcache server if one isn't already set with
307     * that key
308     *
309     * @param string $key Key to set with data
310     * @param mixed $val Value to store
311     * @param int $exp (optional) Expiration time. This can be a number of seconds
312     * to cache for (up to 30 days inclusive).  Any timespans of 30 days + 1 second or
313     * longer must be the timestamp of the time at which the mapping should expire. It
314     * is safe to use timestamps in all cases, regardless of expiration
315     * eg: strtotime("+3 hour")
316     *
317     * @return bool
318     */
319    public function add( $key, $val, $exp = 0 ) {
320        $this->_last_cmd_status = BagOStuff::ERR_NONE;
321
322        return $this->_set( 'add', $key, $val, $exp );
323    }
324
325    // }}}
326    // {{{ decr()
327
328    /**
329     * Decrease a value stored on the memcache server
330     *
331     * @param string $key Key to decrease
332     * @param int $amt (optional) amount to decrease
333     *
334     * @return mixed False on failure, value on success
335     */
336    public function decr( $key, $amt = 1 ) {
337        $this->_last_cmd_status = BagOStuff::ERR_NONE;
338
339        return $this->_incrdecr( 'decr', $key, $amt );
340    }
341
342    // }}}
343    // {{{ delete()
344
345    /**
346     * Deletes a key from the server, optionally after $time
347     *
348     * @param string $key Key to delete
349     * @param int $time (optional) how long to wait before deleting
350     *
351     * @return bool True on success, false on failure
352     */
353    public function delete( $key, $time = 0 ) {
354        $this->_last_cmd_status = BagOStuff::ERR_NONE;
355
356        if ( !$this->_active ) {
357            return false;
358        }
359
360        $sock = $this->get_sock( $key );
361        if ( !$sock ) {
362            return false;
363        }
364
365        $key = is_array( $key ) ? $key[1] : $key;
366
367        if ( isset( $this->stats['delete'] ) ) {
368            $this->stats['delete']++;
369        } else {
370            $this->stats['delete'] = 1;
371        }
372        $cmd = "delete $key $time\r\n";
373        if ( !$this->_fwrite( $sock, $cmd ) ) {
374            return false;
375        }
376        $res = $this->_fgets( $sock );
377
378        if ( $this->_debug ) {
379            $this->_debugprint( sprintf( "MemCache: delete %s (%s)", $key, $res ) );
380        }
381
382        if ( $res == "DELETED" || $res == "NOT_FOUND" ) {
383            return true;
384        }
385
386        $this->_last_cmd_status = BagOStuff::ERR_UNEXPECTED;
387
388        return false;
389    }
390
391    /**
392     * Changes the TTL on a key from the server to $time
393     *
394     * @param string $key
395     * @param int $time TTL in seconds
396     *
397     * @return bool True on success, false on failure
398     */
399    public function touch( $key, $time = 0 ) {
400        $this->_last_cmd_status = BagOStuff::ERR_NONE;
401
402        if ( !$this->_active ) {
403            return false;
404        }
405
406        $sock = $this->get_sock( $key );
407        if ( !$sock ) {
408            return false;
409        }
410
411        $key = is_array( $key ) ? $key[1] : $key;
412
413        if ( isset( $this->stats['touch'] ) ) {
414            $this->stats['touch']++;
415        } else {
416            $this->stats['touch'] = 1;
417        }
418        $cmd = "touch $key $time\r\n";
419        if ( !$this->_fwrite( $sock, $cmd ) ) {
420            return false;
421        }
422        $res = $this->_fgets( $sock );
423
424        if ( $this->_debug ) {
425            $this->_debugprint( sprintf( "MemCache: touch %s (%s)", $key, $res ) );
426        }
427
428        if ( $res == "TOUCHED" ) {
429            return true;
430        }
431
432        return false;
433    }
434
435    // }}}
436    // {{{ disconnect_all()
437
438    /**
439     * Disconnects all connected sockets
440     */
441    public function disconnect_all() {
442        foreach ( $this->_cache_sock as $sock ) {
443            fclose( $sock );
444        }
445
446        $this->_cache_sock = array();
447    }
448
449    // }}}
450    // {{{ enable_compress()
451
452    /**
453     * Enable / Disable compression
454     *
455     * @param bool $enable True to enable, false to disable
456     */
457    public function enable_compress( $enable ) {
458        $this->_compress_enable = $enable;
459    }
460
461    // }}}
462    // {{{ forget_dead_hosts()
463
464    /**
465     * Forget about all of the dead hosts
466     */
467    public function forget_dead_hosts() {
468        $this->_host_dead = array();
469    }
470
471    // }}}
472    // {{{ get()
473
474    /**
475     * Retrieves the value associated with the key from the memcache server
476     *
477     * @param array|string $key key to retrieve
478     * @param float $casToken [optional]
479     *
480     * @return mixed
481     */
482    public function get( $key, &$casToken = null ) {
483        $getToken = ( func_num_args() >= 2 );
484
485        $this->_last_cmd_status = BagOStuff::ERR_NONE;
486
487        if ( $this->_debug ) {
488            $this->_debugprint( "get($key)" );
489        }
490
491        if ( !is_array( $key ) && strval( $key ) === '' ) {
492            $this->_last_cmd_status = BagOStuff::ERR_UNEXPECTED;
493            $this->_debugprint( "Skipping key which equals to an empty string" );
494            return false;
495        }
496
497        if ( !$this->_active ) {
498            $this->_last_cmd_status = BagOStuff::ERR_UNEXPECTED;
499
500            return false;
501        }
502
503        $sock = $this->get_sock( $key );
504
505        if ( !$sock ) {
506            $this->_last_cmd_status = BagOStuff::ERR_UNREACHABLE;
507
508            return false;
509        }
510
511        $key = is_array( $key ) ? $key[1] : $key;
512        if ( isset( $this->stats['get'] ) ) {
513            $this->stats['get']++;
514        } else {
515            $this->stats['get'] = 1;
516        }
517
518        $cmd = $getToken ? "gets" : "get";
519        $cmd .= " $key\r\n";
520        if ( !$this->_fwrite( $sock, $cmd ) ) {
521            $this->_last_cmd_status = BagOStuff::ERR_NO_RESPONSE;
522
523            return false;
524        }
525
526        $val = array();
527        if ( !$this->_load_items( $sock, $val, $casToken ) ) {
528            $this->_last_cmd_status = BagOStuff::ERR_NO_RESPONSE;
529        }
530
531        if ( $this->_debug ) {
532            foreach ( $val as $k => $v ) {
533                $this->_debugprint(
534                    sprintf( "MemCache: sock %s got %s", $this->serialize( $sock ), $k ) );
535            }
536        }
537
538        $value = false;
539        if ( isset( $val[$key] ) ) {
540            $value = $val[$key];
541        }
542        return $value;
543    }
544
545    // }}}
546    // {{{ get_multi()
547
548    /**
549     * Get multiple keys from the server(s)
550     *
551     * @param array $keys Keys to retrieve
552     *
553     * @return array
554     */
555    public function get_multi( $keys ) {
556        $this->_last_cmd_status = BagOStuff::ERR_NONE;
557
558        if ( !$this->_active ) {
559            $this->_last_cmd_status = BagOStuff::ERR_UNEXPECTED;
560
561            return array();
562        }
563
564        if ( isset( $this->stats['get_multi'] ) ) {
565            $this->stats['get_multi']++;
566        } else {
567            $this->stats['get_multi'] = 1;
568        }
569        $sock_keys = array();
570        $socks = array();
571        foreach ( $keys as $key ) {
572            $sock = $this->get_sock( $key );
573            if ( !$sock ) {
574                $this->_last_cmd_status = BagOStuff::ERR_UNREACHABLE;
575                continue;
576            }
577            $key = is_array( $key ) ? $key[1] : $key;
578            $sockValue = intval( $sock );
579
580            if ( !isset( $sock_keys[$sockValue] ) ) {
581                $sock_keys[$sockValue] = array();
582                $socks[] = $sock;
583            }
584            $sock_keys[$sockValue][] = $key;
585        }
586
587        $gather = array();
588        // Send out the requests
589        foreach ( $socks as $sock ) {
590            $cmd = 'get';
591            foreach ( $sock_keys[intval( $sock )] as $key ) {
592                $cmd .= ' ' . $key;
593            }
594            $cmd .= "\r\n";
595
596            if ( $this->_fwrite( $sock, $cmd ) ) {
597                $gather[] = $sock;
598            } else {
599                $this->_last_cmd_status = BagOStuff::ERR_NO_RESPONSE;
600            }
601        }
602
603        // Parse responses
604        $val = array();
605        foreach ( $gather as $sock ) {
606            if ( !$this->_load_items( $sock, $val ) ) {
607                $this->_last_cmd_status = BagOStuff::ERR_NO_RESPONSE;
608            }
609        }
610
611        if ( $this->_debug ) {
612            foreach ( $val as $k => $v ) {
613                $this->_debugprint( sprintf( "MemCache: got %s", $k ) );
614            }
615        }
616
617        return $val;
618    }
619
620    // }}}
621    // {{{ incr()
622
623    /**
624     * Increments $key (optionally) by $amt
625     *
626     * @param string $key Key to increment
627     * @param int $amt (optional) amount to increment
628     *
629     * @return int|null Null if the key does not exist yet (this does NOT
630     * create new mappings if the key does not exist). If the key does
631     * exist, this returns the new value for that key.
632     */
633    public function incr( $key, $amt = 1 ) {
634        return $this->_incrdecr( 'incr', $key, $amt );
635    }
636
637    // }}}
638    // {{{ replace()
639
640    /**
641     * Overwrites an existing value for key; only works if key is already set
642     *
643     * @param string $key Key to set value as
644     * @param mixed $value Value to store
645     * @param int $exp (optional) Expiration time. This can be a number of seconds
646     * to cache for (up to 30 days inclusive).  Any timespans of 30 days + 1 second or
647     * longer must be the timestamp of the time at which the mapping should expire. It
648     * is safe to use timestamps in all cases, regardless of expiration
649     * eg: strtotime("+3 hour")
650     *
651     * @return bool
652     */
653    public function replace( $key, $value, $exp = 0 ) {
654        return $this->_set( 'replace', $key, $value, $exp );
655    }
656
657    // }}}
658    // {{{ run_command()
659
660    /**
661     * Passes through $cmd to the memcache server connected by $sock; returns
662     * output as an array (null array if no output)
663     *
664     * @param Resource $sock Socket to send command on
665     * @param string $cmd Command to run
666     *
667     * @return array Output array
668     */
669    public function run_command( $sock, $cmd ) {
670        if ( !$sock ) {
671            return array();
672        }
673
674        if ( !$this->_fwrite( $sock, $cmd ) ) {
675            return array();
676        }
677
678        $ret = array();
679        while ( true ) {
680            $res = $this->_fgets( $sock );
681            $ret[] = $res;
682            if ( preg_match( '/^END/', $res ) ) {
683                break;
684            }
685            if ( strlen( $res ) == 0 ) {
686                break;
687            }
688        }
689        return $ret;
690    }
691
692    // }}}
693    // {{{ set()
694
695    /**
696     * Unconditionally sets a key to a given value in the memcache.  Returns true
697     * if set successfully.
698     *
699     * @param string $key Key to set value as
700     * @param mixed $value Value to set
701     * @param int $exp (optional) Expiration time. This can be a number of seconds
702     * to cache for (up to 30 days inclusive).  Any timespans of 30 days + 1 second or
703     * longer must be the timestamp of the time at which the mapping should expire. It
704     * is safe to use timestamps in all cases, regardless of expiration
705     * eg: strtotime("+3 hour")
706     *
707     * @return bool True on success
708     */
709    public function set( $key, $value, $exp = 0 ) {
710        return $this->_set( 'set', $key, $value, $exp );
711    }
712
713    // }}}
714    // {{{ cas()
715
716    /**
717     * Sets a key to a given value in the memcache if the current value still corresponds
718     * to a known, given value.  Returns true if set successfully.
719     *
720     * @param float $casToken Current known value
721     * @param string $key Key to set value as
722     * @param mixed $value Value to set
723     * @param int $exp (optional) Expiration time. This can be a number of seconds
724     * to cache for (up to 30 days inclusive).  Any timespans of 30 days + 1 second or
725     * longer must be the timestamp of the time at which the mapping should expire. It
726     * is safe to use timestamps in all cases, regardless of expiration
727     * eg: strtotime("+3 hour")
728     *
729     * @return bool True on success
730     */
731    public function cas( $casToken, $key, $value, $exp = 0 ) {
732        return $this->_set( 'cas', $key, $value, $exp, $casToken );
733    }
734
735    // }}}
736    // {{{ set_compress_threshold()
737
738    /**
739     * Set the compression threshold
740     *
741     * @param int $thresh Threshold to compress if larger than
742     */
743    public function set_compress_threshold( $thresh ) {
744        $this->_compress_threshold = $thresh;
745    }
746
747    // }}}
748    // {{{ set_debug()
749
750    /**
751     * Set the debug flag
752     *
753     * @see __construct()
754     * @param bool $dbg True for debugging, false otherwise
755     */
756    public function set_debug( $dbg ) {
757        $this->_debug = $dbg;
758    }
759
760    // }}}
761    // {{{ set_servers()
762
763    /**
764     * Set the server list to distribute key gets and puts between
765     *
766     * @see __construct()
767     * @param array $list Array of servers to connect to
768     */
769    public function set_servers( $list ) {
770        $this->_servers = $list;
771        $this->_active = count( $list );
772        $this->_buckets = null;
773        $this->_bucketcount = 0;
774
775        $this->_single_sock = null;
776        if ( $this->_active == 1 ) {
777            $this->_single_sock = $this->_servers[0];
778        }
779    }
780
781    /**
782     * Sets the timeout for new connections
783     *
784     * @param int $seconds Number of seconds
785     * @param int $microseconds Number of microseconds
786     */
787    public function set_timeout( $seconds, $microseconds ) {
788        $this->_timeout_seconds = $seconds;
789        $this->_timeout_microseconds = $microseconds;
790    }
791
792    // }}}
793    // }}}
794    // {{{ private methods
795    // {{{ _close_sock()
796
797    /**
798     * Close the specified socket
799     *
800     * @param string $sock Socket to close
801     *
802     * @access private
803     */
804    function _close_sock( $sock ) {
805        $host = array_search( $sock, $this->_cache_sock );
806        fclose( $this->_cache_sock[$host] );
807        unset( $this->_cache_sock[$host] );
808    }
809
810    // }}}
811    // {{{ _connect_sock()
812
813    /**
814     * Connects $sock to $host, timing out after $timeout
815     *
816     * @param int $sock Socket to connect
817     * @param string $host Host:IP to connect to
818     *
819     * @return bool
820     * @access private
821     */
822    function _connect_sock( &$sock, $host ) {
823        $port = null;
824        $hostAndPort = IPUtils::splitHostAndPort( $host );
825        if ( $hostAndPort ) {
826            $ip = $hostAndPort[0];
827            if ( $hostAndPort[1] ) {
828                $port = $hostAndPort[1];
829            }
830        } else {
831            $ip = $host;
832        }
833        $sock = false;
834        $timeout = $this->_connect_timeout;
835        $errno = $errstr = null;
836        for ( $i = 0; !$sock && $i < $this->_connect_attempts; $i++ ) {
837            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
838            if ( $this->_persistent == 1 ) {
839                $sock = @pfsockopen( $ip, $port, $errno, $errstr, $timeout );
840            } else {
841                $sock = @fsockopen( $ip, $port, $errno, $errstr, $timeout );
842            }
843        }
844        if ( !$sock ) {
845            $this->_error_log( "Error connecting to $host$errstr" );
846            $this->_dead_host( $host );
847            return false;
848        }
849
850        // Initialise timeout
851        stream_set_timeout( $sock, $this->_timeout_seconds, $this->_timeout_microseconds );
852
853        // If the connection was persistent, flush the read buffer in case there
854        // was a previous incomplete request on this connection
855        if ( $this->_persistent ) {
856            $this->_flush_read_buffer( $sock );
857        }
858        return true;
859    }
860
861    // }}}
862    // {{{ _dead_sock()
863
864    /**
865     * Marks a host as dead until 30-40 seconds in the future
866     *
867     * @param string $sock Socket to mark as dead
868     *
869     * @access private
870     */
871    function _dead_sock( $sock ) {
872        $host = array_search( $sock, $this->_cache_sock );
873        $this->_dead_host( $host );
874    }
875
876    /**
877     * @param string $host
878     */
879    function _dead_host( $host ) {
880        $hostAndPort = IPUtils::splitHostAndPort( $host );
881        if ( $hostAndPort ) {
882            $ip = $hostAndPort[0];
883        } else {
884            $ip = $host;
885        }
886        $this->_host_dead[$ip] = time() + 30 + intval( rand( 0, 10 ) );
887        $this->_host_dead[$host] = $this->_host_dead[$ip];
888        unset( $this->_cache_sock[$host] );
889    }
890
891    // }}}
892    // {{{ get_sock()
893
894    /**
895     * get_sock
896     *
897     * @param string $key Key to retrieve value for;
898     *
899     * @return Resource|bool Resource on success, false on failure
900     * @access private
901     */
902    function get_sock( $key ) {
903        if ( !$this->_active ) {
904            return false;
905        }
906
907        if ( $this->_single_sock !== null ) {
908            return $this->sock_to_host( $this->_single_sock );
909        }
910
911        $hv = is_array( $key ) ? intval( $key[0] ) : $this->_hashfunc( $key );
912        if ( $this->_buckets === null ) {
913            $bu = array();
914            foreach ( $this->_servers as $v ) {
915                if ( is_array( $v ) ) {
916                    for ( $i = 0; $i < $v[1]; $i++ ) {
917                        $bu[] = $v[0];
918                    }
919                } else {
920                    $bu[] = $v;
921                }
922            }
923            $this->_buckets = $bu;
924            $this->_bucketcount = count( $bu );
925        }
926
927        $realkey = is_array( $key ) ? $key[1] : $key;
928        for ( $tries = 0; $tries < 20; $tries++ ) {
929            $host = $this->_buckets[$hv % $this->_bucketcount];
930            $sock = $this->sock_to_host( $host );
931            if ( $sock ) {
932                return $sock;
933            }
934            $hv = $this->_hashfunc( $hv . $realkey );
935        }
936
937        return false;
938    }
939
940    // }}}
941    // {{{ _hashfunc()
942
943    /**
944     * Creates a hash integer based on the $key
945     *
946     * @param string $key Key to hash
947     *
948     * @return int Hash value
949     * @access private
950     */
951    function _hashfunc( $key ) {
952        # Hash function must be in [0,0x7ffffff]
953        # We take the first 31 bits of the MD5 hash, which unlike the hash
954        # function used in a previous version of this client, works
955        return hexdec( substr( md5( $key ), 0, 8 ) ) & 0x7fffffff;
956    }
957
958    // }}}
959    // {{{ _incrdecr()
960
961    /**
962     * Perform increment/decrement on $key
963     *
964     * @param string $cmd Command to perform
965     * @param string|array $key Key to perform it on
966     * @param int $amt Amount to adjust
967     *
968     * @return int New value of $key
969     * @access private
970     */
971    function _incrdecr( $cmd, $key, $amt = 1 ) {
972        $this->_last_cmd_status = BagOStuff::ERR_NONE;
973
974        if ( !$this->_active ) {
975            $this->_last_cmd_status = BagOStuff::ERR_UNEXPECTED;
976
977            return null;
978        }
979
980        $sock = $this->get_sock( $key );
981        if ( !$sock ) {
982            $this->_last_cmd_status = BagOStuff::ERR_UNREACHABLE;
983
984            return null;
985        }
986
987        $key = is_array( $key ) ? $key[1] : $key;
988        if ( isset( $this->stats[$cmd] ) ) {
989            $this->stats[$cmd]++;
990        } else {
991            $this->stats[$cmd] = 1;
992        }
993        if ( !$this->_fwrite( $sock, "$cmd $key $amt\r\n" ) ) {
994            $this->_last_cmd_status = BagOStuff::ERR_NO_RESPONSE;
995
996            return null;
997        }
998
999        $line = $this->_fgets( $sock );
1000        if ( $this->_debug ) {
1001            $this->_debugprint( "$cmd($key): $line" );
1002        }
1003
1004        $match = array();
1005        if ( !preg_match( '/^(\d+)/', $line, $match ) ) {
1006            $this->_last_cmd_status = BagOStuff::ERR_NO_RESPONSE;
1007
1008            return null;
1009        }
1010
1011        return (int)$match[1];
1012    }
1013
1014    // }}}
1015    // {{{ _load_items()
1016
1017    /**
1018     * Load items into $ret from $sock
1019     *
1020     * @param Resource $sock Socket to read from
1021     * @param array $ret returned values
1022     * @param float $casToken [optional]
1023     * @return bool True for success, false for failure
1024     *
1025     * @access private
1026     */
1027    function _load_items( $sock, &$ret, &$casToken = null ) {
1028        $results = array();
1029
1030        while ( 1 ) {
1031            $decl = $this->_fgets( $sock );
1032
1033            if ( $decl === false ) {
1034                /*
1035                 * If nothing can be read, something is wrong because we know exactly when
1036                 * to stop reading (right after "END") and we return right after that.
1037                 */
1038                return false;
1039            } elseif ( preg_match( '/^VALUE (\S+) (\d+) (\d+)(?: (\d+))?$/', $decl, $match ) ) {
1040                /*
1041                 * Read all data returned. This can be either one or multiple values.
1042                 * Save all that data (in an array) to be processed later: we'll first
1043                 * want to continue reading until "END" before doing anything else,
1044                 * to make sure that we don't leave our client in a state where it's
1045                 * output is not yet fully read.
1046                 */
1047                $results[] = array(
1048                    $match[1], // rkey
1049                    $match[2], // flags
1050                    $match[3], // len
1051                    $match[4] ?? null, // casToken (appears with "gets" but not "get")
1052                    $this->_fread( $sock, $match[3] + 2 ), // data
1053                );
1054            } elseif ( $decl == "END" ) {
1055                /**
1056                 * All data has been read, time to process the data and build
1057                 * meaningful return values.
1058                 */
1059                foreach ( $results as [ $rkey, $flags, /* length */, $casToken, $data ] ) {
1060                    if ( $data === false || !str_ends_with( $data, "\r\n" ) ) {
1061                        $this->_handle_error( $sock,
1062                            'line ending missing from data block from $1' );
1063                        return false;
1064                    }
1065                    $data = substr( $data, 0, -2 );
1066                    $ret[$rkey] = $data;
1067
1068                    if ( $this->_have_zlib && $flags & self::COMPRESSED ) {
1069                        $ret[$rkey] = gzuncompress( $ret[$rkey] );
1070                    }
1071
1072                    /*
1073                     * This unserialize is the exact reason that we only want to
1074                     * process data after having read until "END" (instead of doing
1075                     * this right away): "unserialize" can trigger outside code:
1076                     * in the event that $ret[$rkey] is a serialized object,
1077                     * unserializing it will trigger __wakeup() if present. If that
1078                     * function attempted to read from memcached (while we did not
1079                     * yet read "END"), these 2 calls would collide.
1080                     */
1081                    if ( $flags & self::SERIALIZED ) {
1082                        $ret[$rkey] = $this->unserialize( $ret[$rkey] );
1083                    } elseif ( $flags & self::INTVAL ) {
1084                        $ret[$rkey] = intval( $ret[$rkey] );
1085                    }
1086                }
1087
1088                return true;
1089            } else {
1090                $this->_handle_error( $sock, 'Error parsing response from $1' );
1091                return false;
1092            }
1093        }
1094    }
1095
1096    // }}}
1097    // {{{ _set()
1098
1099    /**
1100     * Performs the requested storage operation to the memcache server
1101     *
1102     * @param string $cmd Command to perform
1103     * @param string $key Key to act on
1104     * @param mixed $val What we need to store
1105     * @param int $exp (optional) Expiration time. This can be a number of seconds
1106     * to cache for (up to 30 days inclusive).  Any timespans of 30 days + 1 second or
1107     * longer must be the timestamp of the time at which the mapping should expire. It
1108     * is safe to use timestamps in all cases, regardless of expiration
1109     * eg: strtotime("+3 hour")
1110     * @param float $casToken [optional]
1111     *
1112     * @return bool
1113     * @access private
1114     */
1115    function _set( $cmd, $key, $val, $exp, $casToken = null ) {
1116        $this->_last_cmd_status = BagOStuff::ERR_NONE;
1117
1118        if ( !$this->_active ) {
1119            $this->_last_cmd_status = BagOStuff::ERR_UNEXPECTED;
1120
1121            return false;
1122        }
1123
1124        $sock = $this->get_sock( $key );
1125        if ( !$sock ) {
1126            $this->_last_cmd_status = BagOStuff::ERR_UNREACHABLE;
1127
1128            return false;
1129        }
1130
1131        if ( isset( $this->stats[$cmd] ) ) {
1132            $this->stats[$cmd]++;
1133        } else {
1134            $this->stats[$cmd] = 1;
1135        }
1136
1137        $flags = 0;
1138
1139        if ( is_int( $val ) ) {
1140            $flags |= self::INTVAL;
1141        } elseif ( !is_scalar( $val ) ) {
1142            $val = $this->serialize( $val );
1143            $flags |= self::SERIALIZED;
1144            if ( $this->_debug ) {
1145                $this->_debugprint( "client: serializing data as it is not scalar" );
1146            }
1147        }
1148
1149        $len = strlen( $val );
1150
1151        if ( $this->_have_zlib && $this->_compress_enable
1152            && $this->_compress_threshold && $len >= $this->_compress_threshold
1153        ) {
1154            $c_val = gzcompress( $val, 9 );
1155            $c_len = strlen( $c_val );
1156
1157            if ( $c_len < $len * ( 1 - self::COMPRESSION_SAVINGS ) ) {
1158                if ( $this->_debug ) {
1159                    $this->_debugprint( sprintf( "client: compressing data; was %d bytes is now %d bytes", $len, $c_len ) );
1160                }
1161                $val = $c_val;
1162                $len = $c_len;
1163                $flags |= self::COMPRESSED;
1164            }
1165        }
1166
1167        $command = "$cmd $key $flags $exp $len";
1168        if ( $casToken ) {
1169            $command .= " $casToken";
1170        }
1171
1172        if ( !$this->_fwrite( $sock, "$command\r\n$val\r\n" ) ) {
1173            $this->_last_cmd_status = BagOStuff::ERR_NO_RESPONSE;
1174
1175            return false;
1176        }
1177
1178        $line = $this->_fgets( $sock );
1179        if ( $this->_debug ) {
1180            $this->_debugprint( sprintf( "%s %s (%s)", $cmd, $key, $line ) );
1181        }
1182
1183        if ( $line === "STORED" ) {
1184            return true;
1185        } elseif ( $line === "NOT_STORED" && $cmd === "set" ) {
1186            // "Not stored" is always used as the mcrouter response with AllAsyncRoute
1187            return true;
1188        }
1189
1190        if ( $line === false ) {
1191            $this->_last_cmd_status = BagOStuff::ERR_NO_RESPONSE;
1192        }
1193
1194        return false;
1195    }
1196
1197    // }}}
1198    // {{{ sock_to_host()
1199
1200    /**
1201     * Returns the socket for the host
1202     *
1203     * @param string $host Host:IP to get socket for
1204     *
1205     * @return Resource|bool IO Stream or false
1206     * @access private
1207     */
1208    function sock_to_host( $host ) {
1209        if ( isset( $this->_cache_sock[$host] ) ) {
1210            return $this->_cache_sock[$host];
1211        }
1212
1213        $sock = null;
1214        $now = time();
1215        $hostAndPort = IPUtils::splitHostAndPort( $host );
1216        if ( $hostAndPort ) {
1217            $ip = $hostAndPort[0];
1218        } else {
1219            $ip = $host;
1220        }
1221        if ( isset( $this->_host_dead[$host] ) && $this->_host_dead[$host] > $now ||
1222            isset( $this->_host_dead[$ip] ) && $this->_host_dead[$ip] > $now
1223        ) {
1224            return null;
1225        }
1226
1227        if ( !$this->_connect_sock( $sock, $host ) ) {
1228            return null;
1229        }
1230
1231        // Do not buffer writes
1232        stream_set_write_buffer( $sock, 0 );
1233
1234        $this->_cache_sock[$host] = $sock;
1235
1236        return $this->_cache_sock[$host];
1237    }
1238
1239    /**
1240     * @param string $text
1241     */
1242    function _debugprint( $text ) {
1243        $this->_logger->debug( $text );
1244    }
1245
1246    /**
1247     * @param string $text
1248     */
1249    function _error_log( $text ) {
1250        $this->_logger->error( "Memcached error: $text" );
1251    }
1252
1253    /**
1254     * Write to a stream. If there is an error, mark the socket dead.
1255     *
1256     * @param Resource $sock The socket
1257     * @param string $buf The string to write
1258     * @return bool True on success, false on failure
1259     */
1260    function _fwrite( $sock, $buf ) {
1261        $bytesWritten = 0;
1262        $bufSize = strlen( $buf );
1263        while ( $bytesWritten < $bufSize ) {
1264            $result = fwrite( $sock, $buf );
1265            $data = stream_get_meta_data( $sock );
1266            if ( $data['timed_out'] ) {
1267                $this->_handle_error( $sock, 'timeout writing to $1' );
1268                return false;
1269            }
1270            // Contrary to the documentation, fwrite() returns zero on error in PHP 5.3.
1271            if ( $result === false || $result === 0 ) {
1272                $this->_handle_error( $sock, 'error writing to $1' );
1273                return false;
1274            }
1275            $bytesWritten += $result;
1276        }
1277
1278        return true;
1279    }
1280
1281    /**
1282     * Handle an I/O error. Mark the socket dead and log an error.
1283     *
1284     * @param Resource $sock
1285     * @param string $msg
1286     */
1287    function _handle_error( $sock, $msg ) {
1288        $peer = stream_socket_get_name( $sock, true /** remote **/ );
1289        if ( strval( $peer ) === '' ) {
1290            $peer = array_search( $sock, $this->_cache_sock );
1291            if ( $peer === false ) {
1292                $peer = '[unknown host]';
1293            }
1294        }
1295        $msg = str_replace( '$1', $peer, $msg );
1296        $this->_error_log( "$msg" );
1297        $this->_dead_sock( $sock );
1298    }
1299
1300    /**
1301     * Read the specified number of bytes from a stream. If there is an error,
1302     * mark the socket dead.
1303     *
1304     * @param Resource $sock The socket
1305     * @param int $len The number of bytes to read
1306     * @return string|bool The string on success, false on failure.
1307     */
1308    function _fread( $sock, $len ) {
1309        $buf = '';
1310        while ( $len > 0 ) {
1311            $result = fread( $sock, $len );
1312            $data = stream_get_meta_data( $sock );
1313            if ( $data['timed_out'] ) {
1314                $this->_handle_error( $sock, 'timeout reading from $1' );
1315                return false;
1316            }
1317            if ( $result === false ) {
1318                $this->_handle_error( $sock, 'error reading buffer from $1' );
1319                return false;
1320            }
1321            if ( $result === '' ) {
1322                // This will happen if the remote end of the socket is shut down
1323                $this->_handle_error( $sock, 'unexpected end of file reading from $1' );
1324                return false;
1325            }
1326            $len -= strlen( $result );
1327            $buf .= $result;
1328        }
1329        return $buf;
1330    }
1331
1332    /**
1333     * Read a line from a stream. If there is an error, mark the socket dead.
1334     * The \r\n line ending is stripped from the response.
1335     *
1336     * @param Resource $sock The socket
1337     * @return string|bool The string on success, false on failure
1338     */
1339    function _fgets( $sock ) {
1340        $result = fgets( $sock );
1341        // fgets() may return a partial line if there is a select timeout after
1342        // a successful recv(), so we have to check for a timeout even if we
1343        // got a string response.
1344        $data = stream_get_meta_data( $sock );
1345        if ( $data['timed_out'] ) {
1346            $this->_handle_error( $sock, 'timeout reading line from $1' );
1347            return false;
1348        }
1349        if ( $result === false ) {
1350            $this->_handle_error( $sock, 'error reading line from $1' );
1351            return false;
1352        }
1353        if ( str_ends_with( $result, "\r\n" ) ) {
1354            $result = substr( $result, 0, -2 );
1355        } elseif ( str_ends_with( $result, "\n" ) ) {
1356            $result = substr( $result, 0, -1 );
1357        } else {
1358            $this->_handle_error( $sock, 'line ending missing in response from $1' );
1359            return false;
1360        }
1361        return $result;
1362    }
1363
1364    /**
1365     * Flush the read buffer of a stream
1366     * @param Resource $f
1367     */
1368    function _flush_read_buffer( $f ) {
1369        if ( !$f ) {
1370            return;
1371        }
1372        $r = array( $f );
1373        $w = null;
1374        $e = null;
1375        $n = stream_select( $r, $w, $e, 0, 0 );
1376        while ( $n == 1 && !feof( $f ) ) {
1377            fread( $f, 1024 );
1378            $r = array( $f );
1379            $w = null;
1380            $e = null;
1381            $n = stream_select( $r, $w, $e, 0, 0 );
1382        }
1383    }
1384
1385    // }}}
1386    // }}}
1387    // }}}
1388}