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