Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.57% covered (danger)
8.57%
15 / 175
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWLBFactory
8.57% covered (danger)
8.57%
15 / 175
16.67% covered (danger)
16.67%
2 / 12
1663.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 applyDefaultConfig
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
182
 getDbTypesWithSchemas
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initServerInfo
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
132
 assertValidServerConfigs
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
90
 reportIfPrefixSet
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 reportMismatchedDBs
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 reportMismatchedPrefixes
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getLBFactoryClass
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 setDomainAliases
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 applyGlobalState
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 logDeprecation
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Generator of database load balancing objects.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Database
22 */
23
24use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
25use MediaWiki\Config\Config;
26use MediaWiki\Config\ServiceOptions;
27use MediaWiki\Deferred\DeferredUpdates;
28use MediaWiki\Logger\LoggerFactory;
29use MediaWiki\MainConfigNames;
30use Wikimedia\Rdbms\ChronologyProtector;
31use Wikimedia\Rdbms\ConfiguredReadOnlyMode;
32use Wikimedia\Rdbms\DatabaseDomain;
33use Wikimedia\Rdbms\IDatabase;
34use Wikimedia\Rdbms\ILBFactory;
35use Wikimedia\RequestTimeout\CriticalSectionProvider;
36
37/**
38 * MediaWiki-specific class for generating database load balancers
39 *
40 * @internal For use by core ServiceWiring only.
41 * @ingroup Database
42 */
43class MWLBFactory {
44
45    /** @var array Cache of already-logged deprecation messages */
46    private static $loggedDeprecations = [];
47
48    public const CORE_VIRTUAL_DOMAINS = [ 'virtual-botpasswords' ];
49
50    /**
51     * @internal For use by ServiceWiring
52     */
53    public const APPLY_DEFAULT_CONFIG_OPTIONS = [
54        MainConfigNames::DBcompress,
55        MainConfigNames::DBDefaultGroup,
56        MainConfigNames::DBmwschema,
57        MainConfigNames::DBname,
58        MainConfigNames::DBpassword,
59        MainConfigNames::DBport,
60        MainConfigNames::DBprefix,
61        MainConfigNames::DBserver,
62        MainConfigNames::DBservers,
63        MainConfigNames::DBssl,
64        MainConfigNames::DBStrictWarnings,
65        MainConfigNames::DBtype,
66        MainConfigNames::DBuser,
67        MainConfigNames::DebugDumpSql,
68        MainConfigNames::DebugLogFile,
69        MainConfigNames::DebugToolbar,
70        MainConfigNames::ExternalServers,
71        MainConfigNames::SQLiteDataDir,
72        MainConfigNames::SQLMode,
73        MainConfigNames::VirtualDomainsMapping,
74    ];
75    /**
76     * @var ServiceOptions
77     */
78    private $options;
79    /**
80     * @var ConfiguredReadOnlyMode
81     */
82    private $readOnlyMode;
83    /**
84     * @var ChronologyProtector
85     */
86    private $chronologyProtector;
87    /**
88     * @var BagOStuff
89     */
90    private $srvCache;
91    /**
92     * @var WANObjectCache
93     */
94    private $wanCache;
95    /**
96     * @var CriticalSectionProvider
97     */
98    private $csProvider;
99    /**
100     * @var StatsdDataFactoryInterface
101     */
102    private $statsdDataFactory;
103    /** @var string[] */
104    private array $virtualDomains;
105
106    /**
107     * @param ServiceOptions $options
108     * @param ConfiguredReadOnlyMode $readOnlyMode
109     * @param ChronologyProtector $chronologyProtector
110     * @param BagOStuff $srvCache
111     * @param WANObjectCache $wanCache
112     * @param CriticalSectionProvider $csProvider
113     * @param StatsdDataFactoryInterface $statsdDataFactory
114     * @param string[] $virtualDomains
115     */
116    public function __construct(
117        ServiceOptions $options,
118        ConfiguredReadOnlyMode $readOnlyMode,
119        ChronologyProtector $chronologyProtector,
120        BagOStuff $srvCache,
121        WANObjectCache $wanCache,
122        CriticalSectionProvider $csProvider,
123        StatsdDataFactoryInterface $statsdDataFactory,
124        array $virtualDomains
125    ) {
126        $this->options = $options;
127        $this->readOnlyMode = $readOnlyMode;
128        $this->chronologyProtector = $chronologyProtector;
129        $this->srvCache = $srvCache;
130        $this->wanCache = $wanCache;
131        $this->csProvider = $csProvider;
132        $this->statsdDataFactory = $statsdDataFactory;
133        $this->virtualDomains = $virtualDomains;
134    }
135
136    /**
137     * @param array $lbConf Config for LBFactory::__construct()
138     * @return array
139     * @internal For use with service wiring
140     */
141    public function applyDefaultConfig( array $lbConf ) {
142        $this->options->assertRequiredOptions( self::APPLY_DEFAULT_CONFIG_OPTIONS );
143
144        $typesWithSchema = self::getDbTypesWithSchemas();
145        if ( Profiler::instance() instanceof ProfilerStub ) {
146            $profilerCallback = null;
147        } else {
148            $profilerCallback = static function ( $section ) {
149                return Profiler::instance()->scopedProfileIn( $section );
150            };
151        }
152
153        $lbConf += [
154            'localDomain' => new DatabaseDomain(
155                $this->options->get( MainConfigNames::DBname ),
156                $this->options->get( MainConfigNames::DBmwschema ),
157                $this->options->get( MainConfigNames::DBprefix )
158            ),
159            'profiler' => $profilerCallback,
160            'trxProfiler' => Profiler::instance()->getTransactionProfiler(),
161            'logger' => LoggerFactory::getInstance( 'rdbms' ),
162            'errorLogger' => [ MWExceptionHandler::class, 'logException' ],
163            'deprecationLogger' => [ static::class, 'logDeprecation' ],
164            'statsdDataFactory' => $this->statsdDataFactory,
165            'cliMode' => MW_ENTRY_POINT === 'cli',
166            'readOnlyReason' => $this->readOnlyMode->getReason(),
167            'defaultGroup' => $this->options->get( MainConfigNames::DBDefaultGroup ),
168            'criticalSectionProvider' => $this->csProvider
169        ];
170
171        $serversCheck = [];
172        // When making changes here, remember to also specify MediaWiki-specific options
173        // for Database classes in the relevant Installer subclass.
174        // Such as MysqlInstaller::openConnection and PostgresInstaller::openConnectionWithParams.
175        if ( $lbConf['class'] === Wikimedia\Rdbms\LBFactorySimple::class ) {
176            if ( isset( $lbConf['servers'] ) ) {
177                // Server array is already explicitly configured
178            } elseif ( is_array( $this->options->get( MainConfigNames::DBservers ) ) ) {
179                $lbConf['servers'] = [];
180                foreach ( $this->options->get( MainConfigNames::DBservers ) as $i => $server ) {
181                    $lbConf['servers'][$i] = self::initServerInfo( $server, $this->options );
182                }
183            } else {
184                $server = self::initServerInfo(
185                    [
186                        'host' => $this->options->get( MainConfigNames::DBserver ),
187                        'user' => $this->options->get( MainConfigNames::DBuser ),
188                        'password' => $this->options->get( MainConfigNames::DBpassword ),
189                        'dbname' => $this->options->get( MainConfigNames::DBname ),
190                        'type' => $this->options->get( MainConfigNames::DBtype ),
191                        'load' => 1
192                    ],
193                    $this->options
194                );
195
196                if ( $this->options->get( MainConfigNames::DBssl ) ) {
197                    $server['ssl'] = true;
198                }
199                $server['flags'] |= $this->options->get( MainConfigNames::DBcompress ) ? DBO_COMPRESS : 0;
200                if ( $this->options->get( MainConfigNames::DBStrictWarnings ) ) {
201                    $server['strictWarnings'] = true;
202                }
203
204                $lbConf['servers'] = [ $server ];
205            }
206            if ( !isset( $lbConf['externalClusters'] ) ) {
207                $lbConf['externalClusters'] = $this->options->get( MainConfigNames::ExternalServers );
208            }
209
210            $serversCheck = $lbConf['servers'];
211        } elseif ( $lbConf['class'] === Wikimedia\Rdbms\LBFactoryMulti::class ) {
212            if ( isset( $lbConf['serverTemplate'] ) ) {
213                if ( in_array( $lbConf['serverTemplate']['type'], $typesWithSchema, true ) ) {
214                    $lbConf['serverTemplate']['schema'] = $this->options->get( MainConfigNames::DBmwschema );
215                }
216                $lbConf['serverTemplate']['sqlMode'] = $this->options->get( MainConfigNames::SQLMode );
217                $serversCheck = [ $lbConf['serverTemplate'] ];
218            }
219        }
220
221        self::assertValidServerConfigs(
222            $serversCheck,
223            $this->options->get( MainConfigNames::DBname ),
224            $this->options->get( MainConfigNames::DBprefix )
225        );
226
227        $lbConf['chronologyProtector'] = $this->chronologyProtector;
228        $lbConf['srvCache'] = $this->srvCache;
229        $lbConf['wanCache'] = $this->wanCache;
230        $lbConf['virtualDomains'] = array_merge( $this->virtualDomains, self::CORE_VIRTUAL_DOMAINS );
231        $lbConf['virtualDomainsMapping'] = $this->options->get( MainConfigNames::VirtualDomainsMapping );
232
233        return $lbConf;
234    }
235
236    /**
237     * @return array
238     */
239    private function getDbTypesWithSchemas() {
240        return [ 'postgres' ];
241    }
242
243    /**
244     * @param array $server
245     * @param ServiceOptions $options
246     * @return array
247     */
248    private function initServerInfo( array $server, ServiceOptions $options ) {
249        if ( $server['type'] === 'sqlite' ) {
250            $httpMethod = $_SERVER['REQUEST_METHOD'] ?? null;
251            // T93097: hint for how file-based databases (e.g. sqlite) should go about locking.
252            // See https://www.sqlite.org/lang_transaction.html
253            // See https://www.sqlite.org/lockingv3.html#shared_lock
254            $isHttpRead = in_array( $httpMethod, [ 'GET', 'HEAD', 'OPTIONS', 'TRACE' ] );
255            if ( MW_ENTRY_POINT === 'rest' && !$isHttpRead ) {
256                // Hack to support some re-entrant invocations using sqlite
257                // See: T259685, T91820
258                $request = \MediaWiki\Rest\EntryPoint::getMainRequest();
259                if ( $request->hasHeader( 'Promise-Non-Write-API-Action' ) ) {
260                    $isHttpRead = true;
261                }
262            }
263            $server += [
264                'dbDirectory' => $options->get( MainConfigNames::SQLiteDataDir ),
265                'trxMode' => $isHttpRead ? 'DEFERRED' : 'IMMEDIATE'
266            ];
267        } elseif ( $server['type'] === 'postgres' ) {
268            $server += [ 'port' => $options->get( MainConfigNames::DBport ) ];
269        }
270
271        if ( in_array( $server['type'], self::getDbTypesWithSchemas(), true ) ) {
272            $server += [ 'schema' => $options->get( MainConfigNames::DBmwschema ) ];
273        }
274
275        $flags = $server['flags'] ?? DBO_DEFAULT;
276        if ( $options->get( MainConfigNames::DebugDumpSql )
277            || $options->get( MainConfigNames::DebugLogFile )
278            || $options->get( MainConfigNames::DebugToolbar )
279        ) {
280            $flags |= DBO_DEBUG;
281        }
282        $server['flags'] = $flags;
283
284        $server += [
285            'tablePrefix' => $options->get( MainConfigNames::DBprefix ),
286            'sqlMode' => $options->get( MainConfigNames::SQLMode ),
287        ];
288
289        return $server;
290    }
291
292    /**
293     * @param array $servers
294     * @param string $ldDB Local domain database name
295     * @param string $ldTP Local domain prefix
296     */
297    private function assertValidServerConfigs( array $servers, $ldDB, $ldTP ) {
298        foreach ( $servers as $server ) {
299            $type = $server['type'] ?? null;
300            $srvDB = $server['dbname'] ?? null; // server DB
301            $srvTP = $server['tablePrefix'] ?? ''; // server table prefix
302
303            if ( $type === 'mysql' ) {
304                // A DB name is not needed to connect to mysql; 'dbname' is useless.
305                // This field only defines the DB to use for unspecified DB domains.
306                if ( $srvDB !== null && $srvDB !== $ldDB ) {
307                    self::reportMismatchedDBs( $srvDB, $ldDB );
308                }
309            } elseif ( $type === 'postgres' ) {
310                if ( $srvTP !== '' ) {
311                    self::reportIfPrefixSet( $srvTP, $type );
312                }
313            }
314
315            if ( $srvTP !== '' && $srvTP !== $ldTP ) {
316                self::reportMismatchedPrefixes( $srvTP, $ldTP );
317            }
318        }
319    }
320
321    /**
322     * @param string $prefix Table prefix
323     * @param string $dbType Database type
324     * @return never
325     */
326    private function reportIfPrefixSet( $prefix, $dbType ) {
327        $e = new UnexpectedValueException(
328            "\$wgDBprefix is set to '$prefix' but the database type is '$dbType'. " .
329            "MediaWiki does not support using a table prefix with this RDBMS type."
330        );
331        MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW );
332        exit;
333    }
334
335    /**
336     * @param string $srvDB Server config database
337     * @param string $ldDB Local DB domain database
338     * @return never
339     */
340    private function reportMismatchedDBs( $srvDB, $ldDB ) {
341        $e = new UnexpectedValueException(
342            "\$wgDBservers has dbname='$srvDB' but \$wgDBname='$ldDB'. " .
343            "Set \$wgDBname to the database used by this wiki project. " .
344            "There is rarely a need to set 'dbname' in \$wgDBservers. " .
345            "Cross-wiki database access, use of WikiMap::getCurrentWikiDbDomain(), " .
346            "use of Database::getDomainId(), and other features are not reliable when " .
347            "\$wgDBservers does not match the local wiki database/prefix."
348        );
349        MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW );
350        exit;
351    }
352
353    /**
354     * @param string $srvTP Server config table prefix
355     * @param string $ldTP Local DB domain database
356     * @return never
357     */
358    private function reportMismatchedPrefixes( $srvTP, $ldTP ) {
359        $e = new UnexpectedValueException(
360            "\$wgDBservers has tablePrefix='$srvTP' but \$wgDBprefix='$ldTP'. " .
361            "Set \$wgDBprefix to the table prefix used by this wiki project. " .
362            "There is rarely a need to set 'tablePrefix' in \$wgDBservers. " .
363            "Cross-wiki database access, use of WikiMap::getCurrentWikiDbDomain(), " .
364            "use of Database::getDomainId(), and other features are not reliable when " .
365            "\$wgDBservers does not match the local wiki database/prefix."
366        );
367        MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW );
368        exit;
369    }
370
371    /**
372     * Decide which LBFactory class to use.
373     *
374     * @internal For use by ServiceWiring
375     * @param array $config (e.g. $wgLBFactoryConf)
376     * @return string Class name
377     */
378    public function getLBFactoryClass( array $config ) {
379        $compat = [
380            // For LocalSettings.php compat after removing underscores (since 1.23).
381            'LBFactory_Single' => Wikimedia\Rdbms\LBFactorySingle::class,
382            'LBFactory_Simple' => Wikimedia\Rdbms\LBFactorySimple::class,
383            'LBFactory_Multi' => Wikimedia\Rdbms\LBFactoryMulti::class,
384            // For LocalSettings.php compat after moving classes to namespaces (since 1.29).
385            'LBFactorySingle' => Wikimedia\Rdbms\LBFactorySingle::class,
386            'LBFactorySimple' => Wikimedia\Rdbms\LBFactorySimple::class,
387            'LBFactoryMulti' => Wikimedia\Rdbms\LBFactoryMulti::class
388        ];
389
390        $class = $config['class'];
391        return $compat[$class] ?? $class;
392    }
393
394    /**
395     * @param ILBFactory $lbFactory
396     */
397    public function setDomainAliases( ILBFactory $lbFactory ) {
398        $domain = DatabaseDomain::newFromId( $lbFactory->getLocalDomainID() );
399        // For compatibility with hyphenated $wgDBname values on older wikis, handle callers
400        // that assume corresponding database domain IDs and wiki IDs have identical values
401        $rawLocalDomain = strlen( $domain->getTablePrefix() )
402            ? "{$domain->getDatabase()}-{$domain->getTablePrefix()}"
403            : (string)$domain->getDatabase();
404
405        $lbFactory->setDomainAliases( [ $rawLocalDomain => $domain ] );
406    }
407
408    /**
409     * Apply global state from the current web request or other PHP process.
410     *
411     * This technically violates the principle constraint on ServiceWiring to be
412     * deterministic for a given site configuration. The exemption made here
413     * is solely to aid in debugging and influence non-nominal behaviour such
414     * as ChronologyProtector. That is, the state applied here must never change
415     * the logical destination or meaning of any database-related methods, it
416     * merely applies preferences and debugging information.
417     *
418     * The code here must be non-essential, with LBFactory behaving the same toward
419     * its consumers regardless of whether this is applied or not.
420     *
421     * For example, something may instantiate LBFactory for the current wiki without
422     * calling this, and its consumers must not be able to tell the difference.
423     * Likewise, in the future MediaWiki may instantiate service wiring and LBFactory
424     * for a foreign wiki in the same farm and apply the current global state to that,
425     * and that should be fine as well.
426     *
427     * @param ILBFactory $lbFactory
428     * @param Config $config
429     * @param IBufferingStatsdDataFactory $stats
430     */
431    public function applyGlobalState(
432        ILBFactory $lbFactory,
433        Config $config,
434        IBufferingStatsdDataFactory $stats
435    ): void {
436        if ( MW_ENTRY_POINT === 'cli' ) {
437            $lbFactory->getMainLB()->setTransactionListener(
438                __METHOD__,
439                static function ( $trigger ) use ( $stats, $config ) {
440                    // During maintenance scripts and PHPUnit integration tests, we let
441                    // DeferredUpdates run immediately from addUpdate(), unless a transaction
442                    // is active. Notify DeferredUpdates after any commit to try now.
443                    // See DeferredUpdates::tryOpportunisticExecute for why.
444                    if ( $trigger === IDatabase::TRIGGER_COMMIT ) {
445                        DeferredUpdates::tryOpportunisticExecute();
446                    }
447                    // Flush stats periodically in long-running CLI scripts to avoid OOM (T181385)
448                    MediaWiki::emitBufferedStatsdData( $stats, $config );
449                }
450            );
451            $lbFactory->setWaitForReplicationListener(
452                __METHOD__,
453                static function () use ( $stats, $config ) {
454                    // Flush stats periodically in long-running CLI scripts to avoid OOM (T181385)
455                    MediaWiki::emitBufferedStatsdData( $stats, $config );
456                }
457            );
458
459        }
460    }
461
462    /**
463     * Log a database deprecation warning
464     *
465     * @param string $msg Deprecation message
466     */
467    public static function logDeprecation( $msg ) {
468        if ( isset( self::$loggedDeprecations[$msg] ) ) {
469            return;
470        }
471        self::$loggedDeprecations[$msg] = true;
472        MWDebug::sendRawDeprecated( $msg, true, wfGetCaller() );
473    }
474}