Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.16% covered (warning)
67.16%
90 / 134
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ObjectCacheFactory
67.16% covered (warning)
67.16%
90 / 134
43.75% covered (danger)
43.75%
7 / 16
167.02
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultKeyspace
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 newFromId
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
8.81
 getInstance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 newFromParams
72.00% covered (warning)
72.00%
18 / 25
0.00% covered (danger)
0.00%
0 / 1
8.08
 prepareSqlBagOStuffFromParams
39.13% covered (danger)
39.13%
9 / 23
0.00% covered (danger)
0.00%
0 / 1
27.27
 prepareMemcachedBagOStuffFromParams
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 prepareMultiWriteBagOStuffFromParams
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 prepareRESTBagOStuffFromParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLocalServerInstance
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 clear
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLocalServerCacheClass
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
12.41
 getAnythingId
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
8.01
 makeLocalServerCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isDatabaseId
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 getLocalClusterInstance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
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 */
20
21use MediaWiki\Config\ServiceOptions;
22use MediaWiki\Deferred\DeferredUpdates;
23use MediaWiki\Http\Telemetry;
24use MediaWiki\Logger\Spi;
25use MediaWiki\MainConfigNames;
26use MediaWiki\MediaWikiServices;
27use Wikimedia\ObjectCache\APCUBagOStuff;
28use Wikimedia\ObjectCache\BagOStuff;
29use Wikimedia\ObjectCache\EmptyBagOStuff;
30use Wikimedia\ObjectCache\HashBagOStuff;
31use Wikimedia\ObjectCache\MemcachedBagOStuff;
32use Wikimedia\ObjectCache\MultiWriteBagOStuff;
33use Wikimedia\ObjectCache\RESTBagOStuff;
34use Wikimedia\Stats\StatsFactory;
35
36/**
37 * Factory for cache objects as configured in the ObjectCaches setting.
38 *
39 * The word "cache" has two main dictionary meanings, and both
40 * are used in this factory class. They are:
41 *
42 *    - a) Cache (the computer science definition).
43 *         A place to store copies or computations on existing data for
44 *         higher access speeds.
45 *    - b) Storage.
46 *         A place to store lightweight data that is not canonically
47 *         stored anywhere else (e.g. a "hoard" of objects).
48 *
49 *  Primary entry points:
50 *
51 *  - ObjectCacheFactory::getLocalServerInstance( $fallbackType )
52 *    Purpose: Memory cache for very hot keys.
53 *    Stored only on the individual web server (typically APC or APCu for web requests,
54 *    and EmptyBagOStuff in CLI mode).
55 *    Not replicated to the other servers.
56 *
57 * - ObjectCacheFactory::getLocalClusterInstance()
58 *    Purpose: Memory storage for per-cluster coordination and tracking.
59 *    A typical use case would be a rate limit counter or cache regeneration mutex.
60 *    Stored centrally within the local data-center. Not replicated to other DCs.
61 *    Configured by $wgMainCacheType.
62 *
63 *  - ObjectCacheFactory::getInstance( $cacheType )
64 *    Purpose: Special cases (like tiered memory/disk caches).
65 *    Get a specific cache type by key in $wgObjectCaches.
66 *
67 *  All the above BagOStuff cache instances have their makeKey()
68 *  method scoped to the *current* wiki ID. Use makeGlobalKey() to avoid this scoping
69 *  when using keys that need to be shared amongst wikis.
70 *
71 * @ingroup Cache
72 * @since 1.42
73 */
74class ObjectCacheFactory {
75    /**
76     * @internal For use by ServiceWiring.php
77     */
78    public const CONSTRUCTOR_OPTIONS = [
79        MainConfigNames::SQLiteDataDir,
80        MainConfigNames::UpdateRowsPerQuery,
81        MainConfigNames::MemCachedServers,
82        MainConfigNames::MemCachedPersistent,
83        MainConfigNames::MemCachedTimeout,
84        MainConfigNames::CachePrefix,
85        MainConfigNames::ObjectCaches,
86        MainConfigNames::MainCacheType,
87        MainConfigNames::MessageCacheType,
88        MainConfigNames::ParserCacheType,
89    ];
90
91    private ServiceOptions $options;
92    private StatsFactory $stats;
93    private Spi $logger;
94    /** @var BagOStuff[] */
95    private $instances = [];
96    private string $domainId;
97    /** @var callable */
98    private $dbLoadBalancerFactory;
99    /**
100     * @internal ObjectCacheFactoryTest only
101     * @var string
102     */
103    public static $localServerCacheClass;
104
105    public function __construct(
106        ServiceOptions $options,
107        StatsFactory $stats,
108        Spi $loggerSpi,
109        callable $dbLoadBalancerFactory,
110        string $domainId
111    ) {
112        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
113        $this->options = $options;
114        $this->stats = $stats;
115        $this->logger = $loggerSpi;
116        $this->dbLoadBalancerFactory = $dbLoadBalancerFactory;
117        $this->domainId = $domainId;
118    }
119
120    /**
121     * Get the default keyspace for this wiki.
122     *
123     * This is either the value of the MainConfigNames::CachePrefix setting
124     * or (if the former is unset) the MainConfigNames::DBname setting, with
125     * MainConfigNames::DBprefix (if defined).
126     *
127     * @return string
128     */
129    private function getDefaultKeyspace(): string {
130        $cachePrefix = $this->options->get( MainConfigNames::CachePrefix );
131        if ( is_string( $cachePrefix ) && $cachePrefix !== '' ) {
132            return $cachePrefix;
133        }
134
135        return $this->domainId;
136    }
137
138    /**
139     * Create a new cache object of the specified type.
140     *
141     * @param string|int $id A key in $wgObjectCaches.
142     * @return BagOStuff
143     */
144    private function newFromId( $id ): BagOStuff {
145        if ( $id === CACHE_ANYTHING ) {
146            $id = $this->getAnythingId();
147        }
148
149        if ( !isset( $this->options->get( MainConfigNames::ObjectCaches )[$id] ) ) {
150            // Always recognize these
151            if ( $id === CACHE_NONE ) {
152                return new EmptyBagOStuff();
153            } elseif ( $id === CACHE_HASH ) {
154                return new HashBagOStuff();
155            } elseif ( $id === CACHE_ACCEL ) {
156                return self::makeLocalServerCache( $this->getDefaultKeyspace() );
157            } elseif ( $id === 'wincache' ) {
158                wfDeprecated( __METHOD__ . ' with cache ID "wincache"', '1.43' );
159                return self::makeLocalServerCache( $this->getDefaultKeyspace() );
160            }
161
162            throw new InvalidArgumentException( "Invalid object cache type \"$id\" requested. " .
163                "It is not present in \$wgObjectCaches." );
164        }
165
166        return $this->newFromParams( $this->options->get( MainConfigNames::ObjectCaches )[$id] );
167    }
168
169    /**
170     * Get a cached instance of the specified type of cache object.
171     *
172     * @param string|int $id A key in $wgObjectCaches.
173     * @return BagOStuff
174     */
175    public function getInstance( $id ): BagOStuff {
176        if ( !isset( $this->instances[$id] ) ) {
177            $this->instances[$id] = $this->newFromId( $id );
178        }
179
180        return $this->instances[$id];
181    }
182
183    /**
184     * Create a new cache object from parameters specification supplied.
185     *
186     * @internal Using this method directly outside of MediaWiki core
187     *   is discouraged. Use getInstance() instead and supply the ID
188     *   of the cache instance to be looked up.
189     *
190     * @param array $params Must have 'factory' or 'class' property.
191     *  - factory: Callback passed $params that returns BagOStuff.
192     *  - class: BagOStuff subclass constructed with $params.
193     *  - loggroup: Alias to set 'logger' key with LoggerFactory group.
194     *  - .. Other parameters passed to factory or class.
195     *
196     * @return BagOStuff
197     */
198    public function newFromParams( array $params ): BagOStuff {
199        $logger = $this->logger->getLogger( $params['loggroup'] ?? 'objectcache' );
200        // Apply default parameters and resolve the logger instance
201        $params += [
202            'logger' => $logger,
203            'keyspace' => $this->getDefaultKeyspace(),
204            'asyncHandler' => [ DeferredUpdates::class, 'addCallableUpdate' ],
205            'reportDupes' => true,
206            'stats' => $this->stats,
207        ];
208
209        if ( isset( $params['factory'] ) ) {
210            $args = $params['args'] ?? [ $params ];
211
212            return call_user_func( $params['factory'], ...$args );
213        }
214
215        if ( !isset( $params['class'] ) ) {
216            throw new InvalidArgumentException(
217                'No "factory" nor "class" provided; got "' . print_r( $params, true ) . '"'
218            );
219        }
220
221        $class = $params['class'];
222
223        // Normalization and DI for SqlBagOStuff
224        if ( is_a( $class, SqlBagOStuff::class, true ) ) {
225            $this->prepareSqlBagOStuffFromParams( $params );
226        }
227
228        // Normalization and DI for MemcachedBagOStuff
229        if ( is_subclass_of( $class, MemcachedBagOStuff::class ) ) {
230            $this->prepareMemcachedBagOStuffFromParams( $params );
231        }
232
233        // Normalization and DI for MultiWriteBagOStuff
234        if ( is_a( $class, MultiWriteBagOStuff::class, true ) ) {
235            $this->prepareMultiWriteBagOStuffFromParams( $params );
236        }
237        if ( is_a( $class, RESTBagOStuff::class, true ) ) {
238            $this->prepareRESTBagOStuffFromParams( $params );
239        }
240
241        return new $class( $params );
242    }
243
244    private function prepareSqlBagOStuffFromParams( array &$params ): void {
245        if ( isset( $params['globalKeyLB'] ) ) {
246            throw new InvalidArgumentException(
247                'globalKeyLB in $wgObjectCaches is no longer supported' );
248        }
249        if ( isset( $params['server'] ) && !isset( $params['servers'] ) ) {
250            $params['servers'] = [ $params['server'] ];
251            unset( $params['server'] );
252        }
253        if ( isset( $params['servers'] ) ) {
254            // In the past it was not required to set 'dbDirectory' in $wgObjectCaches
255            foreach ( $params['servers'] as &$server ) {
256                if ( $server['type'] === 'sqlite' && !isset( $server['dbDirectory'] ) ) {
257                    $server['dbDirectory'] = $this->options->get( MainConfigNames::SQLiteDataDir );
258                }
259            }
260        } elseif ( isset( $params['cluster'] ) ) {
261            $cluster = $params['cluster'];
262            $dbLbFactory = $this->dbLoadBalancerFactory;
263            $params['loadBalancerCallback'] = static function () use ( $cluster, $dbLbFactory ) {
264                return $dbLbFactory()->getExternalLB( $cluster );
265            };
266            $params += [ 'dbDomain' => false ];
267        } else {
268            $dbLbFactory = $this->dbLoadBalancerFactory;
269            $params['loadBalancerCallback'] = static function () use ( $dbLbFactory ) {
270                return $dbLbFactory()->getMainLb();
271            };
272            $params += [ 'dbDomain' => false ];
273        }
274        $params += [ 'writeBatchSize' => $this->options->get( MainConfigNames::UpdateRowsPerQuery ) ];
275    }
276
277    private function prepareMemcachedBagOStuffFromParams( array &$params ): void {
278        $params += [
279            'servers' => $this->options->get( MainConfigNames::MemCachedServers ),
280            'persistent' => $this->options->get( MainConfigNames::MemCachedPersistent ),
281            'timeout' => $this->options->get( MainConfigNames::MemCachedTimeout ),
282        ];
283    }
284
285    private function prepareMultiWriteBagOStuffFromParams( array &$params ): void {
286        // Phan warns about foreach with non-array because it
287        // thinks any key can be Closure|IBufferingStatsdDataFactory
288        '@phan-var array{caches:array[]} $params';
289        foreach ( $params['caches'] ?? [] as $i => $cacheInfo ) {
290            // Ensure logger, keyspace, asyncHandler, etc are injected just as if
291            // one of these was configured without MultiWriteBagOStuff (T318272)
292            $params['caches'][$i] = $this->newFromParams( $cacheInfo );
293        }
294    }
295
296    private function prepareRESTBagOStuffFromParams( array &$params ): void {
297        $params['telemetry'] = Telemetry::getInstance();
298    }
299
300    /**
301     * Factory function for CACHE_ACCEL (referenced from configuration)
302     *
303     * This will look for any APC or APCu style server-local cache.
304     * A fallback cache can be specified if none is found.
305     *
306     *     // Direct calls
307     *     ObjectCache::getLocalServerInstance( $fallbackType );
308     *
309     *     // From $wgObjectCaches via newFromParams()
310     *     ObjectCache::getLocalServerInstance( [ 'fallback' => $fallbackType ] );
311     *
312     * @param int|string|array $fallback Fallback cache or parameter map with 'fallback'
313     * @return BagOStuff
314     * @throws InvalidArgumentException
315     */
316    public function getLocalServerInstance( $fallback = CACHE_NONE ): BagOStuff {
317        $cache = $this->getInstance( CACHE_ACCEL );
318        if ( $cache instanceof EmptyBagOStuff ) {
319            if ( is_array( $fallback ) ) {
320                $fallback = $fallback['fallback'] ?? CACHE_NONE;
321            }
322            $cache = $this->getInstance( $fallback );
323        }
324
325        return $cache;
326    }
327
328    /**
329     * Clear all the cached instances.
330     */
331    public function clear(): void {
332        $this->instances = [];
333    }
334
335    /**
336     * Get the class which will be used for the local server cache
337     * @return string
338     */
339    private static function getLocalServerCacheClass() {
340        if ( self::$localServerCacheClass !== null ) {
341            return self::$localServerCacheClass;
342        }
343        if ( function_exists( 'apcu_fetch' ) ) {
344            // Make sure the APCu methods actually store anything
345            if ( PHP_SAPI !== 'cli' || ini_get( 'apc.enable_cli' ) ) {
346                return APCUBagOStuff::class;
347
348            }
349        }
350
351        return EmptyBagOStuff::class;
352    }
353
354    /**
355     * Get the ID that will be used for CACHE_ANYTHING
356     *
357     * @internal
358     * @return string|int
359     */
360    public function getAnythingId() {
361        $candidates = [
362            $this->options->get( MainConfigNames::MainCacheType ),
363            $this->options->get( MainConfigNames::MessageCacheType ),
364            $this->options->get( MainConfigNames::ParserCacheType )
365        ];
366        foreach ( $candidates as $candidate ) {
367            if ( $candidate === CACHE_ACCEL ) {
368                // CACHE_ACCEL might default to nothing if no APCu
369                // See includes/ServiceWiring.php
370                $class = self::getLocalServerCacheClass();
371                if ( $class !== EmptyBagOStuff::class ) {
372                    return $candidate;
373                }
374            } elseif ( $candidate !== CACHE_NONE && $candidate !== CACHE_ANYTHING ) {
375                return $candidate;
376            }
377        }
378
379        $services = MediaWikiServices::getInstance();
380
381        if ( $services->isServiceDisabled( 'DBLoadBalancer' ) ) {
382            // The DBLoadBalancer service is disabled, so we can't use the database!
383            $candidate = CACHE_NONE;
384        } elseif ( $services->isStorageDisabled() ) {
385            // Storage services are disabled because MediaWikiServices::disableStorage()
386            // was called. This is typically the case during installation.
387            $candidate = CACHE_NONE;
388        } else {
389            $candidate = CACHE_DB;
390        }
391        return $candidate;
392    }
393
394    /**
395     * Create a new BagOStuff instance for local-server caching.
396     *
397     * Only use this if you explicitly require the creation of
398     * a fresh instance. Whenever possible, use or inject the object
399     * from MediaWikiServices::getLocalServerObjectCache() instead.
400     *
401     * NOTE: This method is called very early via Setup.php by ExtensionRegistry,
402     * and thus must remain fairly standalone so as to not cause initialization
403     * of the MediaWikiServices singleton.
404     *
405     * @internal For use by ServiceWiring and ExtensionRegistry. There are use
406     *    cases whereby we want to build up local server cache without service
407     *    wiring available.
408     * @since 1.43, previously on ObjectCache.php since 1.35
409     *
410     * @param string $keyspace
411     * @return BagOStuff
412     */
413    public static function makeLocalServerCache( string $keyspace ) {
414        $params = [
415            'reportDupes' => false,
416            // Even simple caches must use a keyspace (T247562)
417            'keyspace' => $keyspace,
418        ];
419        $class = self::getLocalServerCacheClass();
420        return new $class( $params );
421    }
422
423    /**
424     * Determine whether a config ID would access the database
425     *
426     * @internal For use by ServiceWiring.php
427     * @param string|int $id A key in $wgObjectCaches
428     * @return bool
429     */
430    public function isDatabaseId( $id ) {
431        // NOTE: Sanity check if $id is set to CACHE_ANYTHING and
432        // everything is going through service wiring. CACHE_ANYTHING
433        // would default to CACHE_DB, let's handle that early for cases
434        // where all cache configs are set to CACHE_ANYTHING (T362686).
435        if ( $id === CACHE_ANYTHING ) {
436            $id = $this->getAnythingId();
437            return $this->isDatabaseId( $id );
438        }
439
440        if ( !isset( $this->options->get( MainConfigNames::ObjectCaches )[$id] ) ) {
441            return false;
442        }
443        $cache = $this->options->get( MainConfigNames::ObjectCaches )[$id];
444        if ( ( $cache['class'] ?? '' ) === SqlBagOStuff::class ) {
445            return true;
446        }
447
448        return false;
449    }
450
451    /**
452     * Get the main cluster-local cache object.
453     *
454     * @since 1.43, previously on ObjectCache.php since 1.27
455     * @return BagOStuff
456     */
457    public function getLocalClusterInstance() {
458        return $this->getInstance(
459            $this->options->get( MainConfigNames::MainCacheType )
460        );
461    }
462}