Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 223
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
MemcachedPeclBagOStuff
0.00% covered (danger)
0.00%
0 / 222
0.00% covered (danger)
0.00%
0 / 17
4160
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 initializeClient
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
156
 noReplyScope
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 doGet
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 doSet
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 doCas
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 doDelete
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 doAdd
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 doIncrWithInitAsync
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 doIncrWithInitSync
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 checkResult
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
72
 doGetMulti
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 doSetMulti
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 doDeleteMulti
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 doChangeTTL
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 serialize
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 unserialize
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20namespace Wikimedia\ObjectCache;
21
22use Memcached;
23use RuntimeException;
24use UnexpectedValueException;
25use Wikimedia\ScopedCallback;
26
27/**
28 * Store data on memcached server(s) via the php-memcached PECL extension.
29 *
30 * To use memcached out of the box without any PECL dependency, use the
31 * MemcachedPhpBagOStuff class instead.
32 *
33 * @ingroup Cache
34 */
35class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
36    /** @var Memcached */
37    protected $client;
38
39    /**
40     * Available parameters are:
41     *   - servers:              List of IP:port combinations holding the memcached servers.
42     *   - persistent:           Whether to use a persistent connection
43     *   - compress_threshold:   The minimum size an object must be before it is compressed
44     *   - timeout:              The read timeout in microseconds
45     *   - connect_timeout:      The connect timeout in seconds
46     *   - retry_timeout:        Time in seconds to wait before retrying a failed connect attempt
47     *   - server_failure_limit: Limit for server connect failures before it is removed
48     *   - serializer:           Either "php" or "igbinary". Igbinary produces more compact
49     *                           values, but serialization is much slower unless the php.ini
50     *                           option igbinary.compact_strings is off.
51     *   - use_binary_protocol   Whether to enable the binary protocol (default is ASCII)
52     *   - allow_tcp_nagle_delay Whether to permit Nagle's algorithm for reducing packet count
53     *
54     * @param array $params
55     */
56    public function __construct( $params ) {
57        parent::__construct( $params );
58
59        // Default class-specific parameters
60        $params += [
61            'compress_threshold' => 1500,
62            'connect_timeout' => 0.5,
63            'timeout' => 500_000,
64            'serializer' => 'php',
65            'use_binary_protocol' => false,
66            'allow_tcp_nagle_delay' => true
67        ];
68
69        if ( $params['persistent'] ) {
70            // The pool ID must be unique to the server/option combination.
71            // The Memcached object is essentially shared for each pool ID.
72            // We can only reuse a pool ID if we keep the config consistent.
73            $connectionPoolId = md5( serialize( $params ) );
74            $client = new Memcached( $connectionPoolId );
75        } else {
76            $client = new Memcached();
77        }
78
79        $this->initializeClient( $client, $params );
80
81        $this->client = $client;
82        // The compression threshold is an undocumented php.ini option for some
83        // reason. There's probably not much harm in setting it globally, for
84        // compatibility with the settings for the PHP client.
85        ini_set( 'memcached.compression_threshold', $params['compress_threshold'] );
86    }
87
88    /**
89     * Initialize the client only if needed and reuse it otherwise.
90     * This avoids duplicate servers in the list and new connections.
91     *
92     * @param Memcached $client
93     * @param array $params
94     *
95     * @throws RuntimeException
96     */
97    private function initializeClient( Memcached $client, array $params ) {
98        if ( $client->getServerList() ) {
99            $this->logger->debug( __METHOD__ . ": pre-initialized client instance." );
100
101            return; // preserve persistent handle
102        }
103
104        $this->logger->debug( __METHOD__ . ": initializing new client instance." );
105
106        $options = [
107            Memcached::OPT_NO_BLOCK => false,
108            Memcached::OPT_BUFFER_WRITES => false,
109            Memcached::OPT_NOREPLY => false,
110            // Network protocol (ASCII or binary)
111            Memcached::OPT_BINARY_PROTOCOL => $params['use_binary_protocol'],
112            // Set various network timeouts
113            Memcached::OPT_CONNECT_TIMEOUT => $params['connect_timeout'] * 1000,
114            Memcached::OPT_SEND_TIMEOUT => $params['timeout'],
115            Memcached::OPT_RECV_TIMEOUT => $params['timeout'],
116            Memcached::OPT_POLL_TIMEOUT => $params['timeout'] / 1000,
117            // Avoid pointless delay when sending/fetching large blobs
118            Memcached::OPT_TCP_NODELAY => !$params['allow_tcp_nagle_delay'],
119            // Set libketama mode since it's recommended by the documentation
120            Memcached::OPT_LIBKETAMA_COMPATIBLE => true
121        ];
122        if ( isset( $params['retry_timeout'] ) ) {
123            $options[Memcached::OPT_RETRY_TIMEOUT] = $params['retry_timeout'];
124        }
125        if ( isset( $params['server_failure_limit'] ) ) {
126            $options[Memcached::OPT_SERVER_FAILURE_LIMIT] = $params['server_failure_limit'];
127        }
128        if ( $params['serializer'] === 'php' ) {
129            $options[Memcached::OPT_SERIALIZER] = Memcached::SERIALIZER_PHP;
130        } elseif ( $params['serializer'] === 'igbinary' ) {
131            // @phan-suppress-next-line PhanImpossibleCondition
132            if ( !Memcached::HAVE_IGBINARY ) {
133                throw new RuntimeException(
134                    __CLASS__ . ': the igbinary extension is not available ' .
135                    'but igbinary serialization was requested.'
136                );
137            }
138            $options[Memcached::OPT_SERIALIZER] = Memcached::SERIALIZER_IGBINARY;
139        }
140
141        if ( !$client->setOptions( $options ) ) {
142            throw new RuntimeException(
143                "Invalid options: " . json_encode( $options, JSON_PRETTY_PRINT )
144            );
145        }
146
147        $servers = [];
148        foreach ( $params['servers'] as $host ) {
149            if ( preg_match( '/^\[(.+)\]:(\d+)$/', $host, $m ) ) {
150                $servers[] = [ $m[1], (int)$m[2] ]; // (ip, port)
151            } elseif ( preg_match( '/^([^:]+):(\d+)$/', $host, $m ) ) {
152                $servers[] = [ $m[1], (int)$m[2] ]; // (ip or path, port)
153            } else {
154                $servers[] = [ $host, false ]; // (ip or path, port)
155            }
156        }
157
158        if ( !$client->addServers( $servers ) ) {
159            throw new RuntimeException( "Failed to inject server address list" );
160        }
161    }
162
163    /**
164     * If $flags is true or is an integer with the WRITE_BACKGROUND bit set,
165     * enable no-reply mode, and disable it when the scope object is destroyed.
166     * This makes writes much faster.
167     *
168     * @param bool|int $flags
169     *
170     * @return ScopedCallback|null
171     */
172    private function noReplyScope( $flags ) {
173        if ( $flags !== true && !( $flags & self::WRITE_BACKGROUND ) ) {
174            return null;
175        }
176        $client = $this->client;
177        $client->setOption( Memcached::OPT_NOREPLY, true );
178
179        return new ScopedCallback( static function () use ( $client ) {
180            $client->setOption( Memcached::OPT_NOREPLY, false );
181        } );
182    }
183
184    protected function doGet( $key, $flags = 0, &$casToken = null ) {
185        $getToken = ( $casToken === self::PASS_BY_REF );
186        $casToken = null;
187
188        $this->debug( "get($key)" );
189
190        $routeKey = $this->validateKeyAndPrependRoute( $key );
191
192        // T257003: only require "gets" (instead of "get") when a CAS token is needed
193        if ( $getToken ) {
194            /** @noinspection PhpUndefinedClassConstantInspection */
195            $flags = Memcached::GET_EXTENDED;
196            $res = $this->client->get( $routeKey, null, $flags );
197            if ( is_array( $res ) ) {
198                $result = $res['value'];
199                $casToken = $res['cas'];
200            } else {
201                $result = false;
202            }
203        } else {
204            $result = $this->client->get( $routeKey );
205        }
206
207        return $this->checkResult( $key, $result );
208    }
209
210    protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
211        $this->debug( "set($key)" );
212
213        $routeKey = $this->validateKeyAndPrependRoute( $key );
214
215        $noReplyScope = $this->noReplyScope( $flags );
216        $result = $this->client->set( $routeKey, $value, $this->fixExpiry( $exptime ) );
217        ScopedCallback::consume( $noReplyScope );
218
219        return ( !$result && $this->client->getResultCode() === Memcached::RES_NOTSTORED )
220            // "Not stored" is always used as the mcrouter response with AllAsyncRoute
221            ? true
222            : $this->checkResult( $key, $result );
223    }
224
225    protected function doCas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
226        $this->debug( "cas($key)" );
227
228        $routeKey = $this->validateKeyAndPrependRoute( $key );
229        $result = $this->client->cas(
230            $casToken,
231            $routeKey,
232            $value, $this->fixExpiry( $exptime )
233        );
234
235        return $this->checkResult( $key, $result );
236    }
237
238    protected function doDelete( $key, $flags = 0 ) {
239        $this->debug( "delete($key)" );
240
241        $routeKey = $this->validateKeyAndPrependRoute( $key );
242        $noReplyScope = $this->noReplyScope( $flags );
243        $result = $this->client->delete( $routeKey );
244        ScopedCallback::consume( $noReplyScope );
245
246        return ( !$result && $this->client->getResultCode() === Memcached::RES_NOTFOUND )
247            // "Not found" is counted as success in our interface
248            ? true
249            : $this->checkResult( $key, $result );
250    }
251
252    protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
253        $this->debug( "add($key)" );
254
255        $routeKey = $this->validateKeyAndPrependRoute( $key );
256        $noReplyScope = $this->noReplyScope( $flags );
257        $result = $this->client->add(
258            $routeKey,
259            $value,
260            $this->fixExpiry( $exptime )
261        );
262        ScopedCallback::consume( $noReplyScope );
263
264        return $this->checkResult( $key, $result );
265    }
266
267    protected function doIncrWithInitAsync( $key, $exptime, $step, $init ) {
268        $this->debug( "incrWithInit($key)" );
269        $routeKey = $this->validateKeyAndPrependRoute( $key );
270        $watchPoint = $this->watchErrors();
271        $scope = $this->noReplyScope( true );
272        $this->checkResult( $key, $this->client->add( $routeKey, $init - $step, $this->fixExpiry( $exptime ) ) );
273        $this->checkResult( $key, $this->client->increment( $routeKey, $step ) );
274        ScopedCallback::consume( $scope );
275        $lastError = $this->getLastError( $watchPoint );
276
277        return !$lastError;
278    }
279
280    protected function doIncrWithInitSync( $key, $exptime, $step, $init ) {
281        $this->debug( "incrWithInit($key)" );
282        $routeKey = $this->validateKeyAndPrependRoute( $key );
283        $watchPoint = $this->watchErrors();
284        $result = $this->client->increment( $routeKey, $step );
285        $newValue = $this->checkResult( $key, $result );
286        if ( $newValue === false && !$this->getLastError( $watchPoint ) ) {
287            // No key set; initialize
288            $result = $this->client->add( $routeKey, $init, $this->fixExpiry( $exptime ) );
289            $newValue = $this->checkResult( $key, $result ) ? $init : false;
290            if ( $newValue === false && !$this->getLastError( $watchPoint ) ) {
291                // Raced out initializing; increment
292                $result = $this->client->increment( $routeKey, $step );
293                $newValue = $this->checkResult( $key, $result );
294            }
295        }
296
297        return $newValue;
298    }
299
300    /**
301     * Check the return value from a client method call and take any necessary
302     * action. Returns the value that the wrapper function should return. At
303     * present, the return value is always the same as the return value from
304     * the client, but some day we might find a case where it should be
305     * different.
306     *
307     * @param string|false $key The key used by the caller, or false if there wasn't one.
308     * @param mixed $result The return value
309     *
310     * @return mixed
311     */
312    protected function checkResult( $key, $result ) {
313        static $statusByCode = [
314            Memcached::RES_HOST_LOOKUP_FAILURE => self::ERR_UNREACHABLE,
315            Memcached::RES_SERVER_MARKED_DEAD => self::ERR_UNREACHABLE,
316            Memcached::RES_SERVER_TEMPORARILY_DISABLED => self::ERR_UNREACHABLE,
317            Memcached::RES_UNKNOWN_READ_FAILURE => self::ERR_NO_RESPONSE,
318            Memcached::RES_WRITE_FAILURE => self::ERR_NO_RESPONSE,
319            Memcached::RES_PARTIAL_READ => self::ERR_NO_RESPONSE,
320            // Hard-code values that only exist in recent versions of the PECL extension.
321            // https://github.com/JetBrains/phpstorm-stubs/blob/master/memcached/memcached.php
322            3 /* Memcached::RES_CONNECTION_FAILURE */ => self::ERR_UNREACHABLE,
323            27 /* Memcached::RES_FAIL_UNIX_SOCKET */ => self::ERR_UNREACHABLE,
324            6 /* Memcached::RES_READ_FAILURE */ => self::ERR_NO_RESPONSE
325        ];
326
327        if ( $result !== false ) {
328            return $result;
329        }
330
331        $client = $this->client;
332        $code = $client->getResultCode();
333        switch ( $code ) {
334            case Memcached::RES_SUCCESS:
335                break;
336            case Memcached::RES_DATA_EXISTS:
337            case Memcached::RES_NOTSTORED:
338            case Memcached::RES_NOTFOUND:
339                $this->debug( "result: " . $client->getResultMessage() );
340                break;
341            default:
342                $msg = $client->getResultMessage();
343                $logCtx = [];
344                if ( $key !== false ) {
345                    $server = $client->getServerByKey( $key );
346                    $logCtx['memcached-server'] = "{$server['host']}:{$server['port']}";
347                    $logCtx['memcached-key'] = $key;
348                    $msg = "Memcached error for key \"{memcached-key}\" " .
349                        "on server \"{memcached-server}\": $msg";
350                } else {
351                    $msg = "Memcached error: $msg";
352                }
353                $this->logger->error( $msg, $logCtx );
354                $this->setLastError( $statusByCode[$code] ?? self::ERR_UNEXPECTED );
355        }
356
357        return $result;
358    }
359
360    protected function doGetMulti( array $keys, $flags = 0 ) {
361        $this->debug( 'getMulti(' . implode( ', ', $keys ) . ')' );
362
363        $routeKeys = [];
364        foreach ( $keys as $key ) {
365            $routeKeys[] = $this->validateKeyAndPrependRoute( $key );
366        }
367
368        // The PECL implementation uses multi-key "get"/"gets"; no need to pipeline.
369        // T257003: avoid Memcached::GET_EXTENDED; no tokens are needed and that requires "gets"
370        // https://github.com/libmemcached/libmemcached/blob/eda2becbec24363f56115fa5d16d38a2d1f54775/libmemcached/get.cc#L272
371        $resByRouteKey = $this->client->getMulti( $routeKeys );
372
373        if ( is_array( $resByRouteKey ) ) {
374            $res = [];
375            foreach ( $resByRouteKey as $routeKey => $value ) {
376                $res[$this->stripRouteFromKey( $routeKey )] = $value;
377            }
378        } else {
379            $res = false;
380        }
381
382        $res = $this->checkResult( false, $res );
383
384        return $res !== false ? $res : [];
385    }
386
387    protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
388        $this->debug( 'setMulti(' . implode( ', ', array_keys( $data ) ) . ')' );
389
390        $exptime = $this->fixExpiry( $exptime );
391        $dataByRouteKey = [];
392        foreach ( $data as $key => $value ) {
393            $dataByRouteKey[$this->validateKeyAndPrependRoute( $key )] = $value;
394        }
395
396        $noReplyScope = $this->noReplyScope( $flags );
397
398        // Ignore "failed to set" warning from php-memcached 3.x (T251450)
399        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
400        $result = @$this->client->setMulti( $dataByRouteKey, $exptime );
401        ScopedCallback::consume( $noReplyScope );
402
403        return $this->checkResult( false, $result );
404    }
405
406    protected function doDeleteMulti( array $keys, $flags = 0 ) {
407        $this->debug( 'deleteMulti(' . implode( ', ', $keys ) . ')' );
408
409        $routeKeys = [];
410        foreach ( $keys as $key ) {
411            $routeKeys[] = $this->validateKeyAndPrependRoute( $key );
412        }
413
414        $noReplyScope = $this->noReplyScope( $flags );
415        $resultArray = $this->client->deleteMulti( $routeKeys ) ?: [];
416        ScopedCallback::consume( $noReplyScope );
417
418        $result = true;
419        foreach ( $resultArray as $code ) {
420            if ( !in_array( $code, [ true, Memcached::RES_NOTFOUND ], true ) ) {
421                // "Not found" is counted as success in our interface
422                $result = false;
423            }
424        }
425
426        return $this->checkResult( false, $result );
427    }
428
429    protected function doChangeTTL( $key, $exptime, $flags ) {
430        $this->debug( "touch($key)" );
431
432        $routeKey = $this->validateKeyAndPrependRoute( $key );
433        // Avoid NO_REPLY due to libmemcached hang
434        // https://phabricator.wikimedia.org/T310662#8031692
435        $result = $this->client->touch( $routeKey, $this->fixExpiry( $exptime ) );
436
437        return $this->checkResult( $key, $result );
438    }
439
440    protected function serialize( $value ) {
441        if ( is_int( $value ) ) {
442            return $value;
443        }
444
445        $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
446        if ( $serializer === Memcached::SERIALIZER_PHP ) {
447            return serialize( $value );
448        } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
449            return igbinary_serialize( $value );
450        }
451
452        throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." );
453    }
454
455    protected function unserialize( $value ) {
456        if ( $this->isInteger( $value ) ) {
457            return (int)$value;
458        }
459
460        $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
461        if ( $serializer === Memcached::SERIALIZER_PHP ) {
462            return unserialize( $value );
463        } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
464            return igbinary_unserialize( $value );
465        }
466
467        throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." );
468    }
469}
470
471/** @deprecated class alias since 1.43 */
472class_alias( MemcachedPeclBagOStuff::class, 'MemcachedPeclBagOStuff' );