Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.92% covered (danger)
15.92%
57 / 358
13.04% covered (danger)
13.04%
6 / 46
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediumSpecificBagOStuff
15.97% covered (danger)
15.97%
57 / 357
13.04% covered (danger)
13.04%
6 / 46
12449.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 get
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 trackDuplicateKeys
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
21.27
 doGet
n/a
0 / 0
n/a
0 / 0
0
 set
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 doSet
n/a
0 / 0
n/a
0 / 0
0
 delete
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
14.08
 doDelete
n/a
0 / 0
n/a
0 / 0
0
 add
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 doAdd
n/a
0 / 0
n/a
0 / 0
0
 merge
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 mergeViaCas
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
90
 cas
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 doCas
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 tokensMatch
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 changeTTL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doChangeTTL
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 incrWithInit
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 doIncrWithInit
n/a
0 / 0
n/a
0 / 0
0
 lock
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 doLock
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 unlock
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 doUnlock
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 makeLockKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 deleteObjectsExpiringBefore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMulti
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 doGetMulti
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 setMulti
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 doSetMulti
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 deleteMulti
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 doDeleteMulti
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 changeTTLMulti
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doChangeTTLMulti
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 resolveSegments
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
14.42
 useSegmentationWrapper
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
29.95
 makeValueOrSegmentList
16.00% covered (danger)
16.00%
4 / 25
0.00% covered (danger)
0.00%
0 / 1
19.82
 isRelativeExpiration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getExpirationAsTimestamp
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getExpirationAsTTL
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isInteger
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getQoS
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSegmentationSize
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSegmentedValueMaxSize
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSerialized
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 checkValueSerializability
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 checkIterableMapSerializability
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 serialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 unserialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 debug
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 determinekeyGroupForStats
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 updateOpStats
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
9.09
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\ObjectCache;
7
8use InvalidArgumentException;
9use JsonSerializable;
10use stdClass;
11use Wikimedia\WaitConditionLoop;
12
13/**
14 * Helper classs that implements most of BagOStuff for a backend.
15 *
16 * This should be used by concrete implementations only. Wrapper classes that
17 * proxy another BagOStuff should extend and implement BagOStuff directly.
18 *
19 * @ingroup Cache
20 * @since 1.34
21 */
22abstract class MediumSpecificBagOStuff extends BagOStuff {
23    /** @var array<string,array> Map of (key => (class LOCK_* constant => value) */
24    protected $locks = [];
25    /** @var int Bytes; chunk size of segmented cache values */
26    protected $segmentationSize;
27    /** @var int Bytes; maximum total size of a segmented cache value */
28    protected $segmentedValueMaxSize;
29
30    /** @var float Seconds; maximum expected seconds for a lock ping to reach the backend */
31    protected $maxLockSendDelay = 0.05;
32
33    /** @var array */
34    private $duplicateKeyLookups = [];
35    /** @var bool */
36    private $reportDupes = false;
37    /** @var bool */
38    private $dupeTrackScheduled = false;
39
40    /** Component to use for key construction of blob segment keys */
41    private const SEGMENT_COMPONENT = 'segment';
42
43    /** Idiom for doGet() to return extra information by reference */
44    protected const PASS_BY_REF = -1;
45
46    protected const METRIC_OP_GET = 'get';
47    protected const METRIC_OP_SET = 'set';
48    protected const METRIC_OP_DELETE = 'delete';
49    protected const METRIC_OP_CHANGE_TTL = 'change_ttl';
50    protected const METRIC_OP_ADD = 'add';
51    protected const METRIC_OP_INCR = 'incr';
52    protected const METRIC_OP_DECR = 'decr';
53    protected const METRIC_OP_CAS = 'cas';
54
55    protected const LOCK_RCLASS = 0;
56    protected const LOCK_DEPTH = 1;
57    protected const LOCK_TIME = 2;
58    protected const LOCK_EXPIRY = 3;
59
60    /**
61     * @see BagOStuff::__construct()
62     * Additional $params options include:
63     *   - logger: Psr\Log\LoggerInterface instance
64     *   - reportDupes: Whether to emit warning log messages for all keys that were
65     *      requested more than once (requires an asyncHandler).
66     *   - segmentationSize: The chunk size, in bytes, of segmented values. The value should
67     *      not exceed the maximum size of values in the storage backend, as configured by
68     *      the site administrator.
69     *   - segmentedValueMaxSize: The maximum total size, in bytes, of segmented values.
70     *      This should be configured to a reasonable size give the site traffic and the
71     *      amount of I/O between application and cache servers that the network can handle.
72     *
73     * @param array $params
74     *
75     * @phpcs:ignore Generic.Files.LineLength
76     * @phan-param array{logger?:\Psr\Log\LoggerInterface,asyncHandler?:callable,reportDupes?:bool,segmentationSize?:int|float,segmentedValueMaxSize?:int} $params
77     */
78    public function __construct( array $params = [] ) {
79        parent::__construct( $params );
80
81        if ( !empty( $params['reportDupes'] ) && $this->asyncHandler ) {
82            $this->reportDupes = true;
83        }
84
85        // Default to 8MiB if segmentationSize is not set
86        $this->segmentationSize = $params['segmentationSize'] ?? 8_388_608;
87        // Default to 64MiB if segmentedValueMaxSize is not set
88        $this->segmentedValueMaxSize = $params['segmentedValueMaxSize'] ?? 67_108_864;
89    }
90
91    /**
92     * Get an item with the given key
93     *
94     * If the key includes a deterministic input hash (e.g. the key can only have
95     * the correct value) or complete staleness checks are handled by the caller
96     * (e.g. nothing relies on the TTL), then the READ_VERIFIED flag should be set.
97     * This lets tiered backends know they can safely upgrade a cached value to
98     * higher tiers using standard TTLs.
99     *
100     * @param string $key
101     * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
102     *
103     * @return mixed Returns false on failure or if the item does not exist
104     */
105    public function get( $key, $flags = 0 ) {
106        $this->trackDuplicateKeys( $key );
107
108        return $this->resolveSegments( $key, $this->doGet( $key, $flags ) );
109    }
110
111    /**
112     * Track the number of times that a given key has been used.
113     *
114     * @param string $key
115     */
116    private function trackDuplicateKeys( $key ) {
117        if ( !$this->reportDupes ) {
118            return;
119        }
120
121        if ( !isset( $this->duplicateKeyLookups[$key] ) ) {
122            // Track that we have seen this key. This N-1 counting style allows
123            // easy filtering with array_filter() later.
124            $this->duplicateKeyLookups[$key] = 0;
125        } else {
126            $this->duplicateKeyLookups[$key]++;
127
128            if ( $this->dupeTrackScheduled === false ) {
129                $this->dupeTrackScheduled = true;
130                // Schedule a callback that logs keys processed more than once by get().
131                ( $this->asyncHandler )( function () {
132                    $dups = array_filter( $this->duplicateKeyLookups );
133                    foreach ( $dups as $key => $count ) {
134                        $this->logger->warning(
135                            'Duplicate get(): "{key}" fetched {count} times',
136                            // Count is N-1 of the actual lookup count
137                            [ 'key' => $key, 'count' => $count + 1, ]
138                        );
139                    }
140                } );
141            }
142        }
143    }
144
145    /**
146     * Get an item
147     *
148     * The CAS token should be null if the key does not exist or the value is corrupt
149     *
150     * @param string $key
151     * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
152     * @param mixed &$casToken CAS token if MediumSpecificBagOStuff::PASS_BY_REF [returned]
153     *
154     * @return mixed Returns false on failure or if the item does not exist
155     */
156    abstract protected function doGet( $key, $flags = 0, &$casToken = null );
157
158    /** @inheritDoc */
159    public function set( $key, $value, $exptime = 0, $flags = 0 ) {
160        $entry = $this->makeValueOrSegmentList( $key, $value, $exptime, $flags, $ok );
161
162        // Only when all segments (if any) are stored should the main key be changed
163        return $ok && $this->doSet( $key, $entry, $exptime, $flags );
164    }
165
166    /**
167     * Set an item
168     *
169     * @param string $key
170     * @param mixed $value
171     * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
172     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
173     *
174     * @return bool Success
175     */
176    abstract protected function doSet( $key, $value, $exptime = 0, $flags = 0 );
177
178    /** @inheritDoc */
179    public function delete( $key, $flags = 0 ) {
180        if ( !$this->fieldHasFlags( $flags, self::WRITE_ALLOW_SEGMENTS ) ) {
181            return $this->doDelete( $key, $flags );
182        }
183
184        $mainValue = $this->doGet( $key, self::READ_LATEST );
185        if ( !$this->doDelete( $key, $flags ) ) {
186            return false;
187        }
188
189        if ( !SerializedValueContainer::isSegmented( $mainValue ) ) {
190            // no segments to delete
191            return true;
192        }
193
194        $orderedKeys = array_map(
195            function ( $segmentHash ) use ( $key ) {
196                return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash );
197            },
198            $mainValue->{SerializedValueContainer::SEGMENTED_HASHES}
199        );
200
201        return $this->deleteMulti( $orderedKeys, $flags & ~self::WRITE_ALLOW_SEGMENTS );
202    }
203
204    /**
205     * Delete an item
206     *
207     * @param string $key
208     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
209     *
210     * @return bool True if the item was deleted or not found, false on failure
211     */
212    abstract protected function doDelete( $key, $flags = 0 );
213
214    /** @inheritDoc */
215    public function add( $key, $value, $exptime = 0, $flags = 0 ) {
216        $entry = $this->makeValueOrSegmentList( $key, $value, $exptime, $flags, $ok );
217
218        // Only when all segments (if any) are stored should the main key be changed
219        return $ok && $this->doAdd( $key, $entry, $exptime, $flags );
220    }
221
222    /**
223     * Insert an item if it does not already exist
224     *
225     * @param string $key
226     * @param mixed $value
227     * @param int $exptime
228     * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
229     *
230     * @return bool Success
231     */
232    abstract protected function doAdd( $key, $value, $exptime = 0, $flags = 0 );
233
234    /**
235     * Merge changes into the existing cache value (possibly creating a new one)
236     *
237     * The callback function returns the new value given the current value
238     * (which will be false if not present), and takes the arguments:
239     * (this BagOStuff, cache key, current value, TTL).
240     * The TTL parameter is reference set to $exptime. It can be overridden in the callback.
241     * Nothing is stored nor deleted if the callback returns false.
242     *
243     * @param string $key
244     * @param callable $callback Callback method to be executed
245     * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
246     * @param int $attempts The amount of times to attempt a merge in case of failure
247     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
248     *
249     * @return bool Success
250     */
251    public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
252        return $this->mergeViaCas( $key, $callback, $exptime, $attempts, $flags );
253    }
254
255    /**
256     * @param string $key
257     * @param callable $callback Callback method to be executed
258     * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
259     * @param int $attempts The amount of times to attempt a merge in case of failure
260     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
261     *
262     * @return bool Success
263     * @see BagOStuff::merge()
264     */
265    final protected function mergeViaCas( $key, callable $callback, $exptime, $attempts, $flags ) {
266        $attemptsLeft = $attempts;
267        do {
268            $token = self::PASS_BY_REF;
269            // Get the old value and CAS token from cache
270            $watchPoint = $this->watchErrors();
271            $currentValue = $this->resolveSegments(
272                $key,
273                $this->doGet( $key, $flags, $token )
274            );
275            if ( $this->getLastError( $watchPoint ) ) {
276                // Don't spam slow retries due to network problems (retry only on races)
277                $this->logger->warning(
278                    __METHOD__ . ' failed due to read I/O error on get() for {key}.', [ 'key' => $key ]
279                );
280                $success = false;
281                break;
282            }
283
284            // Derive the new value from the old value
285            $value = $callback( $this, $key, $currentValue, $exptime );
286            $keyWasNonexistent = ( $currentValue === false );
287            $valueMatchesOldValue = ( $value === $currentValue );
288            // free RAM in case the value is large
289            unset( $currentValue );
290
291            $watchPoint = $this->watchErrors();
292            if ( $value === false || $exptime < 0 ) {
293                // do nothing
294                $success = true;
295            } elseif ( $valueMatchesOldValue && $attemptsLeft !== $attempts ) {
296                // recently set by another thread to the same value
297                $success = true;
298            } elseif ( $keyWasNonexistent ) {
299                // Try to create the key, failing if it gets created in the meantime
300                $success = $this->add( $key, $value, $exptime, $flags );
301            } else {
302                // Try to update the key, failing if it gets changed in the meantime
303                $success = $this->cas( $token, $key, $value, $exptime, $flags );
304            }
305            if ( $this->getLastError( $watchPoint ) ) {
306                // Don't spam slow retries due to network problems (retry only on races)
307                $this->logger->warning(
308                    __METHOD__ . ' failed due to write I/O error for {key}.',
309                    [ 'key' => $key ]
310                );
311                $success = false;
312                break;
313            }
314
315        } while ( !$success && --$attemptsLeft );
316
317        return $success;
318    }
319
320    /**
321     * Set an item if the current CAS token matches the provided CAS token
322     *
323     * @param mixed $casToken Only set the item if it still has this CAS token
324     * @param string $key
325     * @param mixed $value
326     * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
327     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
328     *
329     * @return bool Success
330     */
331    protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
332        if ( $casToken === null ) {
333            $this->logger->warning(
334                __METHOD__ . ' got empty CAS token for {key}.',
335                [ 'key' => $key ]
336            );
337
338            // caller may have meant to use add()?
339            return false;
340        }
341
342        $entry = $this->makeValueOrSegmentList( $key, $value, $exptime, $flags, $ok );
343
344        // Only when all segments (if any) are stored should the main key be changed
345        return $ok && $this->doCas( $casToken, $key, $entry, $exptime, $flags );
346    }
347
348    /**
349     * Set an item if the current CAS token matches the provided CAS token
350     *
351     * @param mixed $casToken CAS token from an existing version of the key
352     * @param string $key
353     * @param mixed $value
354     * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
355     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
356     *
357     * @return bool Success
358     */
359    protected function doCas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
360        // @TODO: the use of lock() assumes that all other relevant sets() use a lock
361        if ( !$this->lock( $key, 0 ) ) {
362            // non-blocking
363            return false;
364        }
365
366        $curCasToken = self::PASS_BY_REF;
367        $watchPoint = $this->watchErrors();
368        $exists = ( $this->doGet( $key, self::READ_LATEST, $curCasToken ) !== false );
369        if ( $this->getLastError( $watchPoint ) ) {
370            // Fail if the old CAS token could not be read
371            $success = false;
372            $this->logger->warning(
373                __METHOD__ . ' failed due to write I/O error for {key}.',
374                [ 'key' => $key ]
375            );
376        } elseif ( $exists && $this->tokensMatch( $casToken, $curCasToken ) ) {
377            $success = $this->doSet( $key, $value, $exptime, $flags );
378        } else {
379            // mismatched or failed
380            $success = false;
381            $this->logger->info(
382                __METHOD__ . ' failed due to race condition for {key}.',
383                [ 'key' => $key, 'key_exists' => $exists ]
384            );
385        }
386
387        $this->unlock( $key );
388
389        return $success;
390    }
391
392    /**
393     * @param mixed $value CAS token for an existing key
394     * @param mixed $otherValue CAS token for an existing key
395     *
396     * @return bool Whether the two tokens match
397     */
398    final protected function tokensMatch( $value, $otherValue ) {
399        $type = gettype( $value );
400        // Ideally, tokens are counters, timestamps, hashes, or serialized PHP values.
401        // However, some classes might use the PHP values themselves.
402        if ( $type !== gettype( $otherValue ) ) {
403            return false;
404        }
405        // Serialize both tokens to strictly compare objects or arrays (which might objects
406        // nested inside). Note that this will not apply if integer/string CAS tokens are used.
407        if ( $type === 'array' || $type === 'object' ) {
408            return ( serialize( $value ) === serialize( $otherValue ) );
409        }
410
411        // For string/integer tokens, use a simple comparison
412        return ( $value === $otherValue );
413    }
414
415    /**
416     * Change the expiration on a key if it exists
417     *
418     * If an expiry in the past is given then the key will immediately be expired
419     *
420     * For large values written using WRITE_ALLOW_SEGMENTS, this only changes the TTL of the
421     * main segment list key. While lowering the TTL of the segment list key has the effect of
422     * functionally lowering the TTL of the key, it might leave unused blobs in cache for longer.
423     * Raising the TTL of such keys is not effective, since the expiration of a single segment
424     * key effectively expires the entire value.
425     *
426     * @param string $key
427     * @param int $exptime TTL or UNIX timestamp
428     * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
429     *
430     * @return bool Success Returns false on failure or if the item does not exist
431     * @since 1.28
432     */
433    public function changeTTL( $key, $exptime = 0, $flags = 0 ) {
434        return $this->doChangeTTL( $key, $exptime, $flags );
435    }
436
437    /**
438     * @param string $key
439     * @param int $exptime
440     * @param int $flags
441     *
442     * @return bool
443     */
444    protected function doChangeTTL( $key, $exptime, $flags ) {
445        // @TODO: the use of lock() assumes that all other relevant sets() use a lock
446        if ( !$this->lock( $key, 0 ) ) {
447            return false;
448        }
449
450        $expiry = $this->getExpirationAsTimestamp( $exptime );
451        $delete = ( $expiry != self::TTL_INDEFINITE && $expiry < $this->getCurrentTime() );
452
453        // Use doGet() to avoid having to trigger resolveSegments()
454        $blob = $this->doGet( $key, self::READ_LATEST );
455        if ( $blob ) {
456            if ( $delete ) {
457                $ok = $this->doDelete( $key, $flags );
458            } else {
459                $ok = $this->doSet( $key, $blob, $exptime, $flags );
460            }
461        } else {
462            $ok = false;
463        }
464
465        $this->unlock( $key );
466
467        return $ok;
468    }
469
470    /** @inheritDoc */
471    public function incrWithInit( $key, $exptime, $step = 1, $init = null, $flags = 0 ) {
472        $step = (int)$step;
473        $init = is_int( $init ) ? $init : $step;
474
475        return $this->doIncrWithInit( $key, $exptime, $step, $init, $flags );
476    }
477
478    /**
479     * @param string $key
480     * @param int $exptime
481     * @param int $step
482     * @param int $init
483     * @param int $flags
484     *
485     * @return int|bool New value or false on failure
486     */
487    abstract protected function doIncrWithInit( $key, $exptime, $step, $init, $flags );
488
489    /**
490     * @param string $key
491     * @param int $timeout
492     * @param int $exptime
493     * @param string $rclass
494     *
495     * @return bool
496     */
497    public function lock( $key, $timeout = 6, $exptime = 6, $rclass = '' ) {
498        $exptime = min( $exptime ?: INF, self::TTL_DAY );
499
500        $acquired = false;
501
502        if ( isset( $this->locks[$key] ) ) {
503            // Already locked; avoid deadlocks and allow lock reentry if specified
504            if ( $rclass != '' && $this->locks[$key][self::LOCK_RCLASS] === $rclass ) {
505                ++$this->locks[$key][self::LOCK_DEPTH];
506                $acquired = true;
507            }
508        } else {
509            // Not already locked; acquire a lock on the backend
510            $lockTsUnix = $this->doLock( $key, $timeout, $exptime );
511            if ( $lockTsUnix !== null ) {
512                $this->locks[$key] = [
513                    self::LOCK_RCLASS => $rclass,
514                    self::LOCK_DEPTH  => 1,
515                    self::LOCK_TIME   => $lockTsUnix,
516                    self::LOCK_EXPIRY => $lockTsUnix + $exptime
517                ];
518                $acquired = true;
519            }
520        }
521
522        return $acquired;
523    }
524
525    /**
526     * @see MediumSpecificBagOStuff::lock()
527     *
528     * @param string $key
529     * @param int $timeout Lock wait timeout; 0 for non-blocking [optional]
530     * @param int $exptime Lock time-to-live 1 day maximum [optional]
531     *
532     * @return float|null UNIX timestamp of acquisition; null on failure
533     */
534    protected function doLock( $key, $timeout, $exptime ) {
535        $lockTsUnix = null;
536
537        $fname = __METHOD__;
538        $loop = new WaitConditionLoop(
539            function () use ( $key, $exptime, $fname, &$lockTsUnix ) {
540                $watchPoint = $this->watchErrors();
541                if ( $this->add( $this->makeLockKey( $key ), 1, $exptime ) ) {
542                    $lockTsUnix = microtime( true );
543
544                    return WaitConditionLoop::CONDITION_REACHED;
545                } elseif ( $this->getLastError( $watchPoint ) ) {
546                    $this->logger->warning(
547                        "$fname failed due to I/O error for {key}.",
548                        [ 'key' => $key ]
549                    );
550
551                    return WaitConditionLoop::CONDITION_ABORTED;
552                }
553
554                return WaitConditionLoop::CONDITION_CONTINUE;
555            },
556            $timeout
557        );
558        $code = $loop->invoke();
559
560        if ( $code === $loop::CONDITION_TIMED_OUT ) {
561            $this->logger->warning(
562                "$fname failed due to timeout for {key}.",
563                [ 'key' => $key, 'timeout' => $timeout ]
564            );
565        }
566
567        return $lockTsUnix;
568    }
569
570    /**
571     * Release an advisory lock on a key string
572     *
573     * @param string $key
574     *
575     * @return bool Success
576     */
577    public function unlock( $key ) {
578        $released = false;
579
580        if ( isset( $this->locks[$key] ) ) {
581            if ( --$this->locks[$key][self::LOCK_DEPTH] > 0 ) {
582                $released = true;
583            } else {
584                $released = $this->doUnlock( $key );
585                unset( $this->locks[$key] );
586                if ( !$released ) {
587                    $this->logger->warning(
588                        __METHOD__ . ' failed to release lock for {key}.',
589                        [ 'key' => $key ]
590                    );
591                }
592            }
593        } else {
594            $this->logger->warning(
595                __METHOD__ . ' no lock to release for {key}.',
596                [ 'key' => $key ]
597            );
598        }
599
600        return $released;
601    }
602
603    /**
604     * @see MediumSpecificBagOStuff::unlock()
605     *
606     * @param string $key
607     *
608     * @return bool Success
609     */
610    protected function doUnlock( $key ) {
611        $released = false;
612
613        // Estimate the remaining TTL of the lock key
614        $curTTL = $this->locks[$key][self::LOCK_EXPIRY] - $this->getCurrentTime();
615
616        // Check the risk of race conditions for key deletion
617        if ( $this->getQoS( self::ATTR_DURABILITY ) <= self::QOS_DURABILITY_SCRIPT ) {
618            // Lock (and data) keys use memory specific to this request (e.g. HashBagOStuff)
619            $isSafe = true;
620        } else {
621            // It is unsafe to delete the lock key if there is a serious risk of the key already
622            // being claimed by another thread before the delete operation reaches the backend
623            $isSafe = ( $curTTL > $this->maxLockSendDelay );
624        }
625
626        if ( $isSafe ) {
627            $released = $this->doDelete( $this->makeLockKey( $key ) );
628        } else {
629            $this->logger->warning(
630                "Lock for {key} held too long ({age} sec).",
631                [ 'key' => $key, 'curTTL' => $curTTL ]
632            );
633        }
634
635        return $released;
636    }
637
638    /**
639     * @param string $key
640     *
641     * @return string
642     */
643    protected function makeLockKey( $key ) {
644        return "$key:lock";
645    }
646
647    /** @inheritDoc */
648    public function deleteObjectsExpiringBefore(
649        $timestamp,
650        ?callable $progress = null,
651        $limit = INF,
652        ?string $tag = null
653    ) {
654        return false;
655    }
656
657    /**
658     * Get an associative array containing the item for each of the keys that have items.
659     *
660     * @param string[] $keys List of keys; can be a map of (unused => key) for convenience
661     * @param int $flags Bitfield; supports READ_LATEST [optional]
662     *
663     * @return mixed[] Map of (key => value) for existing keys; preserves the order of $keys
664     */
665    public function getMulti( array $keys, $flags = 0 ) {
666        $foundByKey = $this->doGetMulti( $keys, $flags );
667
668        $res = [];
669        foreach ( $keys as $key ) {
670            // Resolve one blob at a time (avoids too much I/O at once)
671            if ( array_key_exists( $key, $foundByKey ) ) {
672                // A value should not appear in the key if a segment is missing
673                $value = $this->resolveSegments( $key, $foundByKey[$key] );
674                if ( $value !== false ) {
675                    $res[$key] = $value;
676                }
677            }
678        }
679
680        return $res;
681    }
682
683    /**
684     * Get an associative array containing the item for each of the keys that have items.
685     *
686     * @param string[] $keys List of keys
687     * @param int $flags Bitfield; supports READ_LATEST [optional]
688     *
689     * @return array Map of (key => value) for existing keys; preserves the order of $keys
690     */
691    protected function doGetMulti( array $keys, $flags = 0 ) {
692        $res = [];
693        foreach ( $keys as $key ) {
694            $val = $this->doGet( $key, $flags );
695            if ( $val !== false ) {
696                $res[$key] = $val;
697            }
698        }
699
700        return $res;
701    }
702
703    /**
704     * Batch insertion/replace
705     *
706     * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
707     *
708     * @param mixed[] $valueByKey Map of (key => value)
709     * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
710     * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
711     *
712     * @return bool Success
713     * @since 1.24
714     */
715    public function setMulti( array $valueByKey, $exptime = 0, $flags = 0 ) {
716        if ( $this->fieldHasFlags( $flags, self::WRITE_ALLOW_SEGMENTS ) ) {
717            throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' );
718        }
719
720        return $this->doSetMulti( $valueByKey, $exptime, $flags );
721    }
722
723    /**
724     * @param mixed[] $data Map of (key => value)
725     * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
726     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
727     *
728     * @return bool Success
729     */
730    protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
731        $res = true;
732        foreach ( $data as $key => $value ) {
733            $res = $this->doSet( $key, $value, $exptime, $flags ) && $res;
734        }
735
736        return $res;
737    }
738
739    /** @inheritDoc */
740    public function deleteMulti( array $keys, $flags = 0 ) {
741        if ( $this->fieldHasFlags( $flags, self::WRITE_ALLOW_SEGMENTS ) ) {
742            throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' );
743        }
744
745        return $this->doDeleteMulti( $keys, $flags );
746    }
747
748    /**
749     * @param string[] $keys List of keys
750     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
751     *
752     * @return bool Success
753     */
754    protected function doDeleteMulti( array $keys, $flags = 0 ) {
755        $res = true;
756        foreach ( $keys as $key ) {
757            $res = $this->doDelete( $key, $flags ) && $res;
758        }
759
760        return $res;
761    }
762
763    /**
764     * Change the expiration of multiple keys that exist
765     *
766     * @param string[] $keys List of keys
767     * @param int $exptime TTL or UNIX timestamp
768     * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
769     *
770     * @return bool Success
771     *
772     * @since 1.34
773     */
774    public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) {
775        return $this->doChangeTTLMulti( $keys, $exptime, $flags );
776    }
777
778    /**
779     * @param string[] $keys List of keys
780     * @param int $exptime TTL or UNIX timestamp
781     * @param int $flags Bitfield of BagOStuff::WRITE_* constants
782     *
783     * @return bool Success
784     */
785    protected function doChangeTTLMulti( array $keys, $exptime, $flags = 0 ) {
786        $res = true;
787        foreach ( $keys as $key ) {
788            $res = $this->doChangeTTL( $key, $exptime, $flags ) && $res;
789        }
790
791        return $res;
792    }
793
794    /**
795     * Get and reassemble the chunks of blob at the given key
796     *
797     * @param string $key
798     * @param mixed $mainValue
799     *
800     * @return string|null|bool The combined string, false if missing, null on error
801     */
802    final protected function resolveSegments( $key, $mainValue ) {
803        if ( SerializedValueContainer::isSegmented( $mainValue ) ) {
804            $orderedKeys = array_map(
805                function ( $segmentHash ) use ( $key ) {
806                    return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash );
807                },
808                $mainValue->{SerializedValueContainer::SEGMENTED_HASHES}
809            );
810
811            $segmentsByKey = $this->doGetMulti( $orderedKeys );
812
813            $parts = [];
814            foreach ( $orderedKeys as $segmentKey ) {
815                if ( isset( $segmentsByKey[$segmentKey] ) ) {
816                    $parts[] = $segmentsByKey[$segmentKey];
817                } else {
818                    // missing segment
819                    return false;
820                }
821            }
822
823            return $this->unserialize( implode( '', $parts ) );
824        }
825
826        return $mainValue;
827    }
828
829    /**
830     * Check if a value should use a segmentation wrapper due to its size
831     *
832     * In order to avoid extra serialization and/or twice-serialized wrappers, just check if
833     * the value is a large string. Support cache wrappers (e.g. WANObjectCache) that use 2D
834     * arrays to wrap values. This does not recurse in order to avoid overhead from complex
835     * structures and the risk of infinite loops (due to references).
836     *
837     * @param mixed $value
838     * @param int $flags
839     *
840     * @return bool
841     */
842    private function useSegmentationWrapper( $value, $flags ) {
843        if (
844            $this->segmentationSize === INF ||
845            !$this->fieldHasFlags( $flags, self::WRITE_ALLOW_SEGMENTS )
846        ) {
847            return false;
848        }
849
850        if ( is_string( $value ) ) {
851            return ( strlen( $value ) >= $this->segmentationSize );
852        }
853
854        if ( is_array( $value ) ) {
855            // Expect that the contained value will be one of the first array entries
856            foreach ( array_slice( $value, 0, 4 ) as $v ) {
857                if ( is_string( $v ) && strlen( $v ) >= $this->segmentationSize ) {
858                    return true;
859                }
860            }
861        }
862
863        // Avoid breaking functions for incrementing/decrementing integer key values
864        return false;
865    }
866
867    /**
868     * Make the entry to store at a key (inline or segment list), storing any segments
869     *
870     * @param string $key
871     * @param mixed $value
872     * @param int $exptime
873     * @param int $flags
874     * @param mixed|null &$ok Whether the entry is usable (e.g. no missing segments) [returned]
875     *
876     * @return mixed The entry (inline value, wrapped inline value, or wrapped segment list)
877     * @since 1.34
878     */
879    final protected function makeValueOrSegmentList( $key, $value, $exptime, $flags, &$ok ) {
880        $entry = $value;
881        $ok = true;
882
883        if ( $this->useSegmentationWrapper( $value, $flags ) ) {
884            $segmentSize = $this->segmentationSize;
885            $maxTotalSize = $this->segmentedValueMaxSize;
886            $serialized = $this->getSerialized( $value, $key );
887            $size = strlen( $serialized );
888            if ( $size > $maxTotalSize ) {
889                $this->logger->warning(
890                    "Value for {key} exceeds $maxTotalSize bytes; cannot segment.",
891                    [ 'key' => $key ]
892                );
893            } else {
894                // Split the serialized value into chunks and store them at different keys
895                $chunksByKey = [];
896                $segmentHashes = [];
897                $count = intdiv( $size, $segmentSize ) + ( ( $size % $segmentSize ) ? 1 : 0 );
898                for ( $i = 0; $i < $count; ++$i ) {
899                    $segment = substr( $serialized, $i * $segmentSize, $segmentSize );
900                    $hash = sha1( $segment );
901                    $chunkKey = $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $hash );
902                    $chunksByKey[$chunkKey] = $segment;
903                    $segmentHashes[] = $hash;
904                }
905                $flags &= ~self::WRITE_ALLOW_SEGMENTS;
906                $ok = $this->setMulti( $chunksByKey, $exptime, $flags );
907                $entry = SerializedValueContainer::newSegmented( $segmentHashes );
908            }
909        }
910
911        return $entry;
912    }
913
914    /**
915     * @param int|float $exptime
916     *
917     * @return bool Whether the expiry is non-infinite, and, negative or not a UNIX timestamp
918     * @since 1.34
919     */
920    final protected function isRelativeExpiration( $exptime ) {
921        return ( $exptime !== self::TTL_INDEFINITE && $exptime < ( 10 * self::TTL_YEAR ) );
922    }
923
924    /**
925     * Convert an optionally relative timestamp to an absolute time
926     *
927     * The input value will be cast to an integer and interpreted as follows:
928     *   - zero: no expiry; return zero (e.g. TTL_INDEFINITE)
929     *   - negative: relative TTL; return UNIX timestamp offset by this value
930     *   - positive (< 10 years): relative TTL; return UNIX timestamp offset by this value
931     *   - positive (>= 10 years): absolute UNIX timestamp; return this value
932     *
933     * @param int $exptime
934     *
935     * @return int Expiration timestamp or TTL_INDEFINITE for indefinite
936     * @since 1.34
937     */
938    final protected function getExpirationAsTimestamp( $exptime ) {
939        if ( $exptime == self::TTL_INDEFINITE ) {
940            return $exptime;
941        }
942
943        return $this->isRelativeExpiration( $exptime )
944            ? intval( $this->getCurrentTime() + $exptime )
945            : $exptime;
946    }
947
948    /**
949     * Convert an optionally absolute expiry time to a relative time. If an
950     * absolute time is specified which is in the past, use a short expiry time.
951     *
952     * The input value will be cast to an integer and interpreted as follows:
953     *   - zero: no expiry; return zero (e.g. TTL_INDEFINITE)
954     *   - negative: relative TTL; return a short expiry time (1 second)
955     *   - positive (< 10 years): relative TTL; return this value
956     *   - positive (>= 10 years): absolute UNIX timestamp; return offset to current time
957     *
958     * @param int $exptime
959     *
960     * @return int Relative TTL or TTL_INDEFINITE for indefinite
961     * @since 1.34
962     */
963    final protected function getExpirationAsTTL( $exptime ) {
964        if ( $exptime == self::TTL_INDEFINITE ) {
965            return $exptime;
966        }
967
968        return $this->isRelativeExpiration( $exptime )
969            ? $exptime
970            : (int)max( $exptime - $this->getCurrentTime(), 1 );
971    }
972
973    /**
974     * Check if a value is an integer
975     *
976     * @param mixed $value
977     *
978     * @return bool
979     */
980    final protected function isInteger( $value ) {
981        if ( is_int( $value ) ) {
982            return true;
983        } elseif ( !is_string( $value ) ) {
984            return false;
985        }
986
987        $integer = (int)$value;
988
989        return ( $value === (string)$integer );
990    }
991
992    /** @inheritDoc */
993    public function getQoS( $flag ) {
994        return $this->attrMap[$flag] ?? self::QOS_UNKNOWN;
995    }
996
997    /**
998     * @deprecated since 1.43, not used anywhere.
999     */
1000    public function getSegmentationSize() {
1001        wfDeprecated( __METHOD__, '1.43' );
1002
1003        return $this->segmentationSize;
1004    }
1005
1006    /**
1007     * @deprecated since 1.43, not used anywhere.
1008     */
1009    public function getSegmentedValueMaxSize() {
1010        wfDeprecated( __METHOD__, '1.43' );
1011
1012        return $this->segmentedValueMaxSize;
1013    }
1014
1015    /**
1016     * Get the serialized form a value, logging a warning if it involves custom classes
1017     *
1018     * @param mixed $value
1019     * @param string $key
1020     *
1021     * @return string|int String/integer representation of value
1022     * @since 1.35
1023     */
1024    protected function getSerialized( $value, $key ) {
1025        $this->checkValueSerializability( $value, $key );
1026
1027        return $this->serialize( $value );
1028    }
1029
1030    /**
1031     * Log if a new cache value does not appear suitable for serialization at a quick glance
1032     *
1033     * This aids migration of values to JSON-like structures and the debugging of exceptions
1034     * due to serialization failure.
1035     *
1036     * This does not recurse more than one level into container structures.
1037     *
1038     * A proper cache key value is one of the following:
1039     *  - null
1040     *  - a scalar
1041     *  - an array with scalar/null values
1042     *  - an array tree with scalar/null "leaf" values
1043     *  - an stdClass instance with scalar/null field values
1044     *  - an stdClass instance tree with scalar/null "leaf" values
1045     *  - an instance of a class that implements JsonSerializable
1046     *
1047     * @param mixed $value Result of the value generation callback for the key
1048     * @param string $key Cache key
1049     */
1050    private function checkValueSerializability( $value, $key ) {
1051        if ( is_array( $value ) ) {
1052            $this->checkIterableMapSerializability( $value, $key );
1053        } elseif ( is_object( $value ) ) {
1054            // Note that Closure instances count as objects
1055            if ( $value instanceof stdClass ) {
1056                $this->checkIterableMapSerializability( $value, $key );
1057            } elseif ( !( $value instanceof JsonSerializable ) ) {
1058                $this->logger->warning(
1059                    "{class} value for '{cachekey}'; serialization is suspect.",
1060                    [ 'cachekey' => $key, 'class' => get_class( $value ) ]
1061                );
1062            }
1063        }
1064    }
1065
1066    /**
1067     * @param array|stdClass $value Result of the value generation callback for the key
1068     * @param string $key Cache key
1069     */
1070    private function checkIterableMapSerializability( $value, $key ) {
1071        foreach ( $value as $index => $entry ) {
1072            if ( is_object( $entry ) ) {
1073                // Note that Closure instances count as objects
1074                if (
1075                    !( $entry instanceof \stdClass ) &&
1076                    !( $entry instanceof \JsonSerializable )
1077                ) {
1078                    $this->logger->warning(
1079                        "{class} value for '{cachekey}' at '$index'; serialization is suspect.",
1080                        [ 'cachekey' => $key, 'class' => get_class( $entry ) ]
1081                    );
1082
1083                    return;
1084                }
1085            }
1086        }
1087    }
1088
1089    /**
1090     * @param mixed $value
1091     *
1092     * @return string|int|false String/integer representation
1093     * @note Special handling is usually needed for integers so incr()/decr() work
1094     */
1095    protected function serialize( $value ) {
1096        return is_int( $value ) ? $value : serialize( $value );
1097    }
1098
1099    /**
1100     * @param string|int|false $value
1101     *
1102     * @return mixed Original value or false on error
1103     * @note Special handling is usually needed for integers so incr()/decr() work
1104     */
1105    protected function unserialize( $value ) {
1106        return $this->isInteger( $value ) ? (int)$value : unserialize( $value );
1107    }
1108
1109    /**
1110     * @param string $text
1111     */
1112    protected function debug( $text ) {
1113        $this->logger->debug( "{class} debug: $text", [ 'class' => static::class ] );
1114    }
1115
1116    /**
1117     * @param string $key Key generated by BagOStuff::makeKeyInternal
1118     *
1119     * @return string A stats prefix to describe this class of key (e.g. "objectcache.file")
1120     */
1121    private function determinekeyGroupForStats( $key ): string {
1122        // Key came directly from BagOStuff::makeKey() or BagOStuff::makeGlobalKey()
1123        // and thus has the format of "<scope>:<collection>[:<constant or variable>]..."
1124        $components = explode( ':', $key, 3 );
1125        // Handle legacy callers that fail to use the key building methods
1126        $keygroup = $components[1] ?? 'UNKNOWN';
1127
1128        return strtr( $keygroup, '.', '_' );
1129    }
1130
1131    /**
1132     * @param string $op Operation name as a MediumSpecificBagOStuff::METRIC_OP_* constant
1133     * @param array<int,string>|array<string,int[]> $keyInfo Key list, if payload sizes are not
1134     *  applicable, otherwise, map of (key => (send payload size, receive payload size)); send
1135     *  and receive sizes are 0 where not applicable and receive sizes are "false" for keys
1136     *  that were not found during read operations
1137     */
1138    protected function updateOpStats( string $op, array $keyInfo ) {
1139        $deltasByMetric = [];
1140
1141        foreach ( $keyInfo as $indexOrKey => $keyOrSizes ) {
1142            if ( is_array( $keyOrSizes ) ) {
1143                $key = $indexOrKey;
1144                [ $sPayloadSize, $rPayloadSize ] = $keyOrSizes;
1145            } else {
1146                $key = $keyOrSizes;
1147                $sPayloadSize = 0;
1148                $rPayloadSize = 0;
1149            }
1150
1151            // Metric prefix for the cache wrapper and key collection name
1152            $keygroup = $this->determinekeyGroupForStats( $key );
1153
1154            if ( $op === self::METRIC_OP_GET ) {
1155                // This operation was either a "hit" or "miss" for this key
1156                if ( $rPayloadSize === false ) {
1157                    $statsName = "bagostuff_miss_total";
1158                } else {
1159                    $statsName = "bagostuff_hit_total";
1160                }
1161            } else {
1162                // There is no concept of "hit" or "miss" for this operation
1163                $statsName = "bagostuff_call_total";
1164            }
1165            $deltasByMetric[$statsName][$keygroup] = ( $deltasByMetric[$statsName][$keygroup] ?? 0 ) + 1;
1166
1167            if ( $sPayloadSize > 0 ) {
1168                $statsName = "bagostuff_bytes_sent_total";
1169                $deltasByMetric[$statsName][$keygroup] =
1170                    ( $deltasByMetric[$statsName][$keygroup] ?? 0 ) + $sPayloadSize;
1171            }
1172
1173            if ( $rPayloadSize > 0 ) {
1174                $statsName = "bagostuff_bytes_read_total";
1175                $deltasByMetric[$statsName][$keygroup] =
1176                    ( $deltasByMetric[$statsName][$keygroup] ?? 0 ) + $rPayloadSize;
1177            }
1178        }
1179
1180        foreach ( $deltasByMetric as $statsName => $deltaByKeygroup ) {
1181            $stats = $this->stats->getCounter( $statsName );
1182            foreach ( $deltaByKeygroup as $keygroup => $delta ) {
1183                $stats->setLabel( 'keygroup', $keygroup )
1184                    ->setLabel( 'operation', $op )
1185                    ->incrementBy( $delta );
1186            }
1187        }
1188    }
1189}
1190
1191/** @deprecated class alias since 1.43 */
1192class_alias( MediumSpecificBagOStuff::class, 'MediumSpecificBagOStuff' );