Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.21% covered (warning)
56.21%
172 / 306
26.09% covered (danger)
26.09%
12 / 46
CRAP
0.00% covered (danger)
0.00%
0 / 1
LBFactory
56.21% covered (warning)
56.21%
172 / 306
26.09% covered (danger)
26.09%
12 / 46
1641.86
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 configure
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
7.05
 destroy
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 autoReconfigure
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 reconfigure
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getLocalDomainID
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shutdown
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getAllLBs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getLBsForOwner
n/a
0 / 0
n/a
0 / 0
0
 flushReplicaSnapshots
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 beginPrimaryChanges
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
4.37
 commitPrimaryChanges
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
8.51
 rollbackPrimaryChanges
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 flushPrimarySessions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 executePostTransactionCallbacks
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
6.56
 hasTransactionRound
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isReadyForRoundOperations
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logIfMultiDbTransaction
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
8.12
 hasPrimaryChanges
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 laggedReplicaUsed
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 hasOrMadeRecentPrimaryChanges
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 waitForReplication
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
11.27
 setWaitForReplicationListener
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getEmptyTransactionTicket
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getPrimaryDatabase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAutoCommitPrimaryConnection
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getReplicaDatabase
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getLoadBalancer
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getMappedDatabase
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMappedDomain
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 isLocalDomain
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 isSharedVirtualDomain
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 commitAndWaitForReplication
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 disableChronologyProtection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDefaultGroupName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 baseLoadBalancerParams
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
3
 initLoadBalancer
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setTableAliases
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDomainAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTransactionProfiler
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setLocalDomainPrefix
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 redefineLocalDomain
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 closeAll
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setAgentName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasStreamingReplicaServers
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 setDefaultReplicationWaitTimeout
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 assertTransactionRoundStage
16.67% covered (danger)
16.67%
1 / 6
0.00% covered (danger)
0.00%
0 / 1
4.31
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\Rdbms;
7
8use Exception;
9use Generator;
10use Psr\Log\LoggerInterface;
11use Psr\Log\NullLogger;
12use RuntimeException;
13use Throwable;
14use Wikimedia\ObjectCache\BagOStuff;
15use Wikimedia\ObjectCache\EmptyBagOStuff;
16use Wikimedia\ObjectCache\WANObjectCache;
17use Wikimedia\RequestTimeout\CriticalSectionProvider;
18use Wikimedia\ScopedCallback;
19use Wikimedia\Stats\StatsFactory;
20use Wikimedia\Telemetry\NoopTracer;
21use Wikimedia\Telemetry\TracerInterface;
22
23/**
24 * @see ILBFactory
25 * @ingroup Database
26 */
27abstract class LBFactory implements ILBFactory {
28    /** @var CriticalSectionProvider|null */
29    private $csProvider;
30    /**
31     * @var callable|null An optional callback that returns a ScopedCallback instance,
32     * meant to profile the actual query execution in {@see Database::doQuery}
33     */
34    private $profiler;
35    /** @var TransactionProfiler */
36    private $trxProfiler;
37    /** @var TracerInterface */
38    private $tracer;
39    /** @var StatsFactory */
40    private $statsFactory;
41    /** @var LoggerInterface */
42    private $logger;
43    /** @var callable Error logger */
44    private $errorLogger;
45    /** @var callable Deprecation logger */
46    private $deprecationLogger;
47
48    /** @var ChronologyProtector */
49    protected $chronologyProtector;
50    /** @var BagOStuff */
51    protected $srvCache;
52    /** @var WANObjectCache */
53    protected $wanCache;
54    /** @var DatabaseDomain Local domain */
55    protected $localDomain;
56
57    /** @var bool Whether this PHP instance is for a CLI script */
58    private $cliMode;
59    /** @var string Agent name for query profiling */
60    private $agent;
61
62    /** @var array[] $aliases Map of (table => (dbname, schema, prefix) map) */
63    private $tableAliases = [];
64    /** @var DatabaseDomain[]|string[] Map of (domain alias => DB domain) */
65    protected $domainAliases = [];
66    /** @var array[] Map of virtual domain to array of cluster and domain */
67    protected array $virtualDomainsMapping = [];
68    /** @var string[] List of registered virtual domains */
69    protected array $virtualDomains = [];
70    /** @var callable[] */
71    private $replicationWaitCallbacks = [];
72
73    /** @var int|null Ticket used to delegate transaction ownership */
74    private $ticket;
75    /** @var string|null Active explicit transaction round owner or null if none */
76    private $trxRoundFname = null;
77    /** @var string One of the ROUND_* class constants */
78    private $trxRoundStage = self::ROUND_CURSORY;
79    /** @var int Default replication wait timeout */
80    private $replicationWaitTimeout;
81
82    /** @var string|false Reason all LBs are read-only or false if not */
83    protected $readOnlyReason = false;
84
85    /** @var string|null */
86    private $defaultGroup = null;
87    private bool $shuffleSharding = false;
88    private ?string $uniqueIdentifier = null;
89
90    private const ROUND_CURSORY = 'cursory';
91    private const ROUND_BEGINNING = 'within-begin';
92    private const ROUND_COMMITTING = 'within-commit';
93    private const ROUND_ROLLING_BACK = 'within-rollback';
94    private const ROUND_COMMIT_CALLBACKS = 'within-commit-callbacks';
95    private const ROUND_ROLLBACK_CALLBACKS = 'within-rollback-callbacks';
96    private const ROUND_ROLLBACK_SESSIONS = 'within-rollback-session';
97
98    /**
99     * @var callable
100     */
101    private $configCallback = null;
102
103    public function __construct( array $conf ) {
104        $this->configure( $conf );
105
106        if ( isset( $conf['configCallback'] ) ) {
107            $this->configCallback = $conf['configCallback'];
108        }
109    }
110
111    protected function configure( array $conf ): void {
112        $this->localDomain = isset( $conf['localDomain'] )
113            ? DatabaseDomain::newFromId( $conf['localDomain'] )
114            : DatabaseDomain::newUnspecified();
115
116        if ( isset( $conf['readOnlyReason'] ) && is_string( $conf['readOnlyReason'] ) ) {
117            $this->readOnlyReason = $conf['readOnlyReason'];
118        }
119
120        $this->chronologyProtector = $conf['chronologyProtector'] ?? new ChronologyProtector();
121        $this->srvCache = $conf['srvCache'] ?? new EmptyBagOStuff();
122        $this->wanCache = $conf['wanCache'] ?? WANObjectCache::newEmpty();
123
124        $this->logger = $conf['logger'] ?? new NullLogger();
125        $this->errorLogger = $conf['errorLogger'] ?? static function ( Throwable $e ) {
126                trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
127        };
128        $this->deprecationLogger = $conf['deprecationLogger'] ?? static function ( $msg ) {
129                trigger_error( $msg, E_USER_DEPRECATED );
130        };
131
132        $this->profiler = $conf['profiler'] ?? null;
133        $this->trxProfiler = $conf['trxProfiler'] ?? new TransactionProfiler();
134        $this->statsFactory = $conf['statsFactory'] ?? StatsFactory::newNull();
135        $this->tracer = $conf['tracer'] ?? new NoopTracer();
136
137        $this->csProvider = $conf['criticalSectionProvider'] ?? null;
138
139        $this->cliMode = $conf['cliMode'] ?? ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' );
140        $this->agent = $conf['agent'] ?? '';
141        $this->replicationWaitTimeout = $this->cliMode ? 60 : 1;
142        $this->virtualDomainsMapping = $conf['virtualDomainsMapping'] ?? [];
143        $this->virtualDomains = $conf['virtualDomains'] ?? [];
144
145        $this->shuffleSharding = $conf['shuffleSharding'] ?? false;
146        $this->uniqueIdentifier = $conf['uniqueIdentifier'] ?? null;
147
148        static $nextTicket;
149        $this->ticket = $nextTicket = ( is_int( $nextTicket ) ? $nextTicket++ : mt_rand() );
150    }
151
152    public function destroy() {
153        /** @noinspection PhpUnusedLocalVariableInspection */
154        $scope = ScopedCallback::newScopedIgnoreUserAbort();
155
156        foreach ( $this->getLBsForOwner() as $lb ) {
157            $lb->disable( __METHOD__ );
158        }
159    }
160
161    /**
162     * Reload config using the callback passed defined $config['configCallback'].
163     *
164     * If the config returned by the callback is different from the existing config,
165     * this calls reconfigure() on all load balancers, which causes them to invalidate
166     * any existing connections and re-connect using the new configuration.
167     *
168     * Long-running processes should call this from time to time
169     * (but not too often, because it is somewhat expensive),
170     * preferably after each batch.
171     * Maintenance scripts can do that by calling $this->waitForReplication(),
172     * which calls this method.
173     */
174    public function autoReconfigure(): void {
175        if ( !$this->configCallback ) {
176            return;
177        }
178
179        $conf = ( $this->configCallback )();
180        if ( $conf ) {
181            $this->reconfigure( $conf );
182        }
183    }
184
185    /**
186     * Reconfigure using the given config array.
187     * Any fields omitted from $conf will be taken from the current config.
188     *
189     * If the config changed, this calls reconfigure() on all load balancers,
190     * which causes them to close all existing connections.
191     *
192     * @note This invalidates the current transaction ticket.
193     *
194     * @warning This must only be called in top level code such as the execute()
195     * method of a maintenance script. Any database connection in use when this
196     * method is called will become defunct.
197     *
198     * @since 1.39
199     *
200     * @param array $conf A configuration array, using the same structure as
201     *        the one passed to the constructor (see also $wgLBFactoryConf).
202     */
203    public function reconfigure( array $conf ): void {
204        if ( !$conf ) {
205            return;
206        }
207
208        foreach ( $this->getLBsForOwner() as $lb ) {
209            $lb->reconfigure( $conf );
210        }
211    }
212
213    public function getLocalDomainID(): string {
214        return $this->localDomain->getId();
215    }
216
217    /** @inheritDoc */
218    public function shutdown(
219        $flags = self::SHUTDOWN_NORMAL,
220        ?callable $workCallback = null,
221        &$cpIndex = null,
222        &$cpClientId = null
223    ) {
224        /** @noinspection PhpUnusedLocalVariableInspection */
225        $scope = ScopedCallback::newScopedIgnoreUserAbort();
226
227        if ( ( $flags & self::SHUTDOWN_NO_CHRONPROT ) != self::SHUTDOWN_NO_CHRONPROT ) {
228            // Remark all of the relevant DB primary positions
229            foreach ( $this->getLBsForOwner() as $lb ) {
230                if ( $lb->hasPrimaryConnection() ) {
231                    $this->chronologyProtector->stageSessionPrimaryPos( $lb );
232                }
233            }
234            // Write the positions to the persistent stash
235            $this->chronologyProtector->persistSessionReplicationPositions( $cpIndex );
236            $this->logger->debug( __METHOD__ . ': finished ChronologyProtector shutdown' );
237        }
238        $cpClientId = $this->chronologyProtector->getClientId();
239
240        $this->commitPrimaryChanges( __METHOD__ );
241
242        $this->logger->debug( 'LBFactory shutdown completed' );
243    }
244
245    /** @inheritDoc */
246    public function getAllLBs() {
247        foreach ( $this->getLBsForOwner() as $lb ) {
248            yield $lb;
249        }
250    }
251
252    /**
253     * Get all tracked load balancers with the internal "for owner" interface.
254     *
255     * @return Generator|ILoadBalancerForOwner[]
256     */
257    abstract protected function getLBsForOwner();
258
259    /** @inheritDoc */
260    public function flushReplicaSnapshots( $fname = __METHOD__ ) {
261        if ( $this->trxRoundFname !== null && $this->trxRoundFname !== $fname ) {
262            $this->logger->warning(
263                "$fname: transaction round '{$this->trxRoundFname}' still running",
264                [ 'exception' => new RuntimeException() ]
265            );
266        }
267        foreach ( $this->getLBsForOwner() as $lb ) {
268            $lb->flushReplicaSnapshots( $fname );
269        }
270    }
271
272    /** @inheritDoc */
273    final public function beginPrimaryChanges( $fname = __METHOD__ ) {
274        $this->assertTransactionRoundStage( self::ROUND_CURSORY );
275        /** @noinspection PhpUnusedLocalVariableInspection */
276        $scope = ScopedCallback::newScopedIgnoreUserAbort();
277
278        foreach ( $this->getLBsForOwner() as $lb ) {
279            $lb->flushReplicaSnapshots( $fname );
280        }
281
282        $this->trxRoundStage = self::ROUND_BEGINNING;
283        if ( $this->trxRoundFname !== null ) {
284            throw new DBTransactionError(
285                null,
286                "$fname: transaction round '{$this->trxRoundFname}' already started"
287            );
288        }
289        $this->trxRoundFname = $fname;
290        // Flush snapshots and appropriately set DBO_TRX on primary connections
291        foreach ( $this->getLBsForOwner() as $lb ) {
292            $lb->beginPrimaryChanges( $fname );
293        }
294        $this->trxRoundStage = self::ROUND_CURSORY;
295    }
296
297    /** @inheritDoc */
298    final public function commitPrimaryChanges( $fname = __METHOD__, int $maxWriteDuration = 0 ) {
299        $this->assertTransactionRoundStage( self::ROUND_CURSORY );
300        /** @noinspection PhpUnusedLocalVariableInspection */
301        $scope = ScopedCallback::newScopedIgnoreUserAbort();
302
303        $this->trxRoundStage = self::ROUND_COMMITTING;
304        if ( $this->trxRoundFname !== null && $this->trxRoundFname !== $fname ) {
305            throw new DBTransactionError(
306                null,
307                "$fname: transaction round '{$this->trxRoundFname}' still running"
308            );
309        }
310        // Run pre-commit callbacks and suppress post-commit callbacks, aborting on failure
311        do {
312            $count = 0; // number of callbacks executed this iteration
313            foreach ( $this->getLBsForOwner() as $lb ) {
314                $count += $lb->finalizePrimaryChanges( $fname );
315            }
316        } while ( $count > 0 );
317        $this->trxRoundFname = null;
318        // Perform pre-commit checks, aborting on failure
319        foreach ( $this->getLBsForOwner() as $lb ) {
320            $lb->approvePrimaryChanges( $maxWriteDuration, $fname );
321        }
322        // Log the DBs and methods involved in multi-DB transactions
323        $this->logIfMultiDbTransaction();
324        // Actually perform the commit on all primary DB connections and revert DBO_TRX
325        foreach ( $this->getLBsForOwner() as $lb ) {
326            $lb->commitPrimaryChanges( $fname );
327        }
328        // Run all post-commit callbacks in a separate step
329        $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS;
330        $e = $this->executePostTransactionCallbacks();
331        $this->trxRoundStage = self::ROUND_CURSORY;
332        // Throw any last post-commit callback error
333        if ( $e instanceof Exception ) {
334            // FIXME: The possible checked exception types should be better documented
335            // @phan-suppress-next-line PhanThrowTypeMismatch
336            throw $e;
337        }
338
339        foreach ( $this->getLBsForOwner() as $lb ) {
340            $lb->flushReplicaSnapshots( $fname );
341        }
342    }
343
344    /** @inheritDoc */
345    final public function rollbackPrimaryChanges( $fname = __METHOD__ ) {
346        /** @noinspection PhpUnusedLocalVariableInspection */
347        $scope = ScopedCallback::newScopedIgnoreUserAbort();
348
349        $this->trxRoundStage = self::ROUND_ROLLING_BACK;
350        $this->trxRoundFname = null;
351        // Actually perform the rollback on all primary DB connections and revert DBO_TRX
352        foreach ( $this->getLBsForOwner() as $lb ) {
353            $lb->rollbackPrimaryChanges( $fname );
354        }
355        // Run all post-commit callbacks in a separate step
356        $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS;
357        $this->executePostTransactionCallbacks();
358        $this->trxRoundStage = self::ROUND_CURSORY;
359
360        foreach ( $this->getLBsForOwner() as $lb ) {
361            $lb->flushReplicaSnapshots( $fname );
362        }
363    }
364
365    /** @inheritDoc */
366    final public function flushPrimarySessions( $fname = __METHOD__ ) {
367        /** @noinspection PhpUnusedLocalVariableInspection */
368        $scope = ScopedCallback::newScopedIgnoreUserAbort();
369
370        // Release named locks and table locks on all primary DB connections
371        $this->trxRoundStage = self::ROUND_ROLLBACK_SESSIONS;
372        foreach ( $this->getLBsForOwner() as $lb ) {
373            $lb->flushPrimarySessions( $fname );
374        }
375        $this->trxRoundStage = self::ROUND_CURSORY;
376    }
377
378    /**
379     * @return Exception|null
380     */
381    private function executePostTransactionCallbacks() {
382        $fname = __METHOD__;
383        // Run all post-commit callbacks until new ones stop getting added
384        $e = null; // first callback exception
385        $iterations = 0;
386        do {
387            // Run any callbacks on tracked load balancers
388            foreach ( $this->getLBsForOwner() as $lb ) {
389                $ex = $lb->runPrimaryTransactionIdleCallbacks( $fname );
390                $e = $e ?: $ex;
391            }
392            // T392913: log and break when this method seems to be in an obviously broken loop
393            if ( ++$iterations >= 32 ) {
394                throw new DBTransactionError(
395                    null,
396                    "Aborting likely infinite callback loop due to unresolvable pending writes"
397                );
398            }
399        } while ( $this->hasPrimaryChanges() );
400        // Run all listener callbacks once
401        foreach ( $this->getLBsForOwner() as $lb ) {
402            $ex = $lb->runPrimaryTransactionListenerCallbacks( $fname );
403            $e = $e ?: $ex;
404        }
405
406        return $e;
407    }
408
409    /** @inheritDoc */
410    public function hasTransactionRound() {
411        // TODO: check for implicit rounds or rename and check for implicit rounds with writes?
412        return ( $this->trxRoundFname !== null );
413    }
414
415    /** @inheritDoc */
416    public function isReadyForRoundOperations() {
417        return ( $this->trxRoundStage === self::ROUND_CURSORY );
418    }
419
420    /**
421     * Log query info if multi DB transactions are going to be committed now
422     */
423    private function logIfMultiDbTransaction() {
424        $callersByDB = [];
425        foreach ( $this->getLBsForOwner() as $lb ) {
426            $primaryName = $lb->getServerName( ServerInfo::WRITER_INDEX );
427            $callers = $lb->pendingPrimaryChangeCallers();
428            if ( $callers ) {
429                $callersByDB[$primaryName] = $callers;
430            }
431        }
432
433        if ( count( $callersByDB ) >= 2 ) {
434            $dbs = implode( ', ', array_keys( $callersByDB ) );
435            $msg = "Multi-DB transaction [{$dbs}]:\n";
436            foreach ( $callersByDB as $db => $callers ) {
437                $msg .= "$db" . implode( '; ', $callers ) . "\n";
438            }
439            $this->logger->info( $msg );
440        }
441    }
442
443    /** @inheritDoc */
444    public function hasPrimaryChanges() {
445        foreach ( $this->getLBsForOwner() as $lb ) {
446            if ( $lb->hasPrimaryChanges() ) {
447                return true;
448            }
449        }
450        return false;
451    }
452
453    /** @inheritDoc */
454    public function laggedReplicaUsed() {
455        foreach ( $this->getLBsForOwner() as $lb ) {
456            if ( $lb->laggedReplicaUsed() ) {
457                return true;
458            }
459        }
460        return false;
461    }
462
463    /** @inheritDoc */
464    public function hasOrMadeRecentPrimaryChanges( $age = null ) {
465        foreach ( $this->getLBsForOwner() as $lb ) {
466            if ( $lb->hasOrMadeRecentPrimaryChanges( $age ) ) {
467                return true;
468            }
469        }
470        return false;
471    }
472
473    /** @inheritDoc */
474    public function waitForReplication( array $opts = [] ) {
475        $opts += [
476            'timeout' => $this->replicationWaitTimeout,
477            'ifWritesSince' => null
478        ];
479
480        $lbs = [];
481        foreach ( $this->getLBsForOwner() as $lb ) {
482            $lbs[] = $lb;
483        }
484
485        // Get all the primary DB positions of applicable DBs right now.
486        // This can be faster since waiting on one cluster reduces the
487        // time needed to wait on the next clusters.
488        $primaryPositions = array_fill( 0, count( $lbs ), false );
489        foreach ( $lbs as $i => $lb ) {
490            if (
491                // No writes to wait on getting replicated
492                !$lb->hasPrimaryConnection() ||
493                // No replication; avoid getPrimaryPos() permissions errors (T29975)
494                !$lb->hasStreamingReplicaServers() ||
495                // No writes since the last replication wait
496                (
497                    $opts['ifWritesSince'] &&
498                    $lb->lastPrimaryChangeTimestamp() < $opts['ifWritesSince']
499                )
500            ) {
501                continue; // no need to wait
502            }
503
504            $primaryPositions[$i] = $lb->getPrimaryPos();
505        }
506
507        // Run any listener callbacks *after* getting the DB positions. The more
508        // time spent in the callbacks, the less time is spent in waitForAll().
509        foreach ( $this->replicationWaitCallbacks as $callback ) {
510            $callback();
511        }
512
513        $failed = [];
514        foreach ( $lbs as $i => $lb ) {
515            if ( $primaryPositions[$i] ) {
516                // The RDBMS may not support getPrimaryPos()
517                if ( !$lb->waitForAll( $primaryPositions[$i], $opts['timeout'] ) ) {
518                    $failed[] = $lb->getServerName( ServerInfo::WRITER_INDEX );
519                }
520            }
521        }
522
523        return !$failed;
524    }
525
526    /** @inheritDoc */
527    public function setWaitForReplicationListener( $name, ?callable $callback = null ) {
528        if ( $callback ) {
529            $this->replicationWaitCallbacks[$name] = $callback;
530        } else {
531            unset( $this->replicationWaitCallbacks[$name] );
532        }
533    }
534
535    /** @inheritDoc */
536    public function getEmptyTransactionTicket( $fname ) {
537        if ( $this->hasPrimaryChanges() ) {
538            $this->logger->error(
539                __METHOD__ . "$fname does not have outer scope",
540                [ 'exception' => new RuntimeException() ]
541            );
542
543            return null;
544        }
545
546        return $this->ticket;
547    }
548
549    /** @inheritDoc */
550    public function getPrimaryDatabase( $domain = false ): IDatabase {
551        return $this->getMappedDatabase( DB_PRIMARY, [], $domain );
552    }
553
554    /** @inheritDoc */
555    public function getAutoCommitPrimaryConnection( $domain = false ): IDatabase {
556        return $this->getLoadBalancer( $domain )
557            ->getConnection( DB_PRIMARY, [], $this->getMappedDomain( $domain ), ILoadBalancer::CONN_TRX_AUTOCOMMIT );
558    }
559
560    /** @inheritDoc */
561    public function getReplicaDatabase( string|false $domain = false, $group = null ): IReadableDatabase {
562        if ( $group === null ) {
563            $groups = [];
564        } else {
565            $groups = [ $group ];
566        }
567        return $this->getMappedDatabase( DB_REPLICA, $groups, $domain );
568    }
569
570    /** @inheritDoc */
571    public function getLoadBalancer( $domain = false ): ILoadBalancer {
572        if ( $domain !== false && in_array( $domain, $this->virtualDomains ) ) {
573            if ( isset( $this->virtualDomainsMapping[$domain] ) ) {
574                $config = $this->virtualDomainsMapping[$domain];
575                if ( isset( $config['cluster'] ) ) {
576                    return $this->getExternalLB( $config['cluster'] );
577                }
578                $domain = $config['db'];
579            } else {
580                // It's not configured, assume local db.
581                $domain = false;
582            }
583        }
584        return $this->getMainLB( $domain );
585    }
586
587    /**
588     * Helper for getPrimaryDatabase and getReplicaDatabase() providing virtual
589     * domain mapping.
590     *
591     * @param int $index
592     * @param array $groups
593     * @param string|false $domain
594     * @return IDatabase
595     */
596    private function getMappedDatabase( $index, $groups, string|false $domain ) {
597        return $this->getLoadBalancer( $domain )
598            ->getConnection( $index, $groups, $this->getMappedDomain( $domain ) );
599    }
600
601    /**
602     * @internal For installer and getMappedDatabase
603     */
604    public function getMappedDomain( string|false $domain ): string|false {
605        if ( $domain !== false && in_array( $domain, $this->virtualDomains ) ) {
606            return $this->virtualDomainsMapping[$domain]['db'] ?? false;
607        } else {
608            return $domain;
609        }
610    }
611
612    /**
613     * Determine whether, after mapping, the domain refers to the main domain
614     * of the local wiki.
615     *
616     * @internal for installer
617     * @param string|false $domain
618     * @return bool
619     */
620    public function isLocalDomain( $domain ) {
621        if ( $domain !== false && in_array( $domain, $this->virtualDomains ) ) {
622            if ( isset( $this->virtualDomainsMapping[$domain] ) ) {
623                $config = $this->virtualDomainsMapping[$domain];
624                if ( isset( $config['cluster'] ) ) {
625                    // In an external cluster
626                    return false;
627                }
628                $domain = $config['db'];
629            } else {
630                // Unconfigured virtual domain is always local
631                return true;
632            }
633        }
634        return $domain === false || $domain === $this->getLocalDomainID();
635    }
636
637    /**
638     * Is the domain a virtual domain with a statically configured database name?
639     *
640     * @internal for installer
641     * @param string|false $domain
642     * @return bool
643     */
644    public function isSharedVirtualDomain( $domain ) {
645        if ( $domain !== false
646            && in_array( $domain, $this->virtualDomains )
647            && isset( $this->virtualDomainsMapping[$domain] )
648        ) {
649            return $this->virtualDomainsMapping[$domain]['db'] !== false;
650        }
651        return false;
652    }
653
654    /** @inheritDoc */
655    final public function commitAndWaitForReplication( $fname, $ticket, array $opts = [] ) {
656        if ( $ticket !== $this->ticket ) {
657            $this->logger->error(
658                __METHOD__ . "$fname does not have outer scope ($ticket vs {$this->ticket})",
659                [ 'exception' => new RuntimeException() ]
660            );
661
662            return false;
663        }
664
665        // The transaction owner and any caller with the empty transaction ticket can commit
666        // so that getEmptyTransactionTicket() callers don't risk seeing DBTransactionError.
667        if ( $this->trxRoundFname !== null && $fname !== $this->trxRoundFname ) {
668            $this->logger->info( "$fname: committing on behalf of {$this->trxRoundFname}" );
669            $fnameEffective = $this->trxRoundFname;
670        } else {
671            $fnameEffective = $fname;
672        }
673
674        $this->commitPrimaryChanges( $fnameEffective );
675        $waitSucceeded = $this->waitForReplication( $opts );
676        // If a nested caller committed on behalf of $fname, start another empty $fname
677        // transaction, leaving the caller with the same empty transaction state as before.
678        if ( $fnameEffective !== $fname ) {
679            $this->beginPrimaryChanges( $fnameEffective );
680        }
681
682        return $waitSucceeded;
683    }
684
685    public function disableChronologyProtection() {
686        $this->chronologyProtector->setEnabled( false );
687    }
688
689    public function setDefaultGroupName( string $defaultGroup ): void {
690        // for future LBs
691        $this->defaultGroup = $defaultGroup;
692
693        // For existing LBs
694        foreach ( $this->getLBsForOwner() as $lb ) {
695            $lb->setDefaultGroupName( $defaultGroup );
696        }
697    }
698
699    /**
700     * Get parameters to ILoadBalancer::__construct()
701     *
702     * @return array
703     */
704    final protected function baseLoadBalancerParams() {
705        if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) {
706            $initStage = ILoadBalancerForOwner::STAGE_POSTCOMMIT_CALLBACKS;
707        } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) {
708            $initStage = ILoadBalancerForOwner::STAGE_POSTROLLBACK_CALLBACKS;
709        } else {
710            $initStage = null;
711        }
712
713        return [
714            'localDomain' => $this->localDomain,
715            'readOnlyReason' => $this->readOnlyReason,
716            'srvCache' => $this->srvCache,
717            'wanCache' => $this->wanCache,
718            'profiler' => $this->profiler,
719            'trxProfiler' => $this->trxProfiler,
720            'tracer' => $this->tracer,
721            'logger' => $this->logger,
722            'errorLogger' => $this->errorLogger,
723            'deprecationLogger' => $this->deprecationLogger,
724            'statsFactory' => $this->statsFactory,
725            'cliMode' => $this->cliMode,
726            'agent' => $this->agent,
727            'defaultGroup' => $this->defaultGroup,
728            'chronologyProtector' => $this->chronologyProtector,
729            'roundStage' => $initStage,
730            'criticalSectionProvider' => $this->csProvider,
731            'shuffleSharding' => $this->shuffleSharding,
732            'uniqueIdentifier' => $this->uniqueIdentifier,
733        ];
734    }
735
736    protected function initLoadBalancer( ILoadBalancerForOwner $lb ) {
737        if ( $this->trxRoundFname !== null ) {
738            $lb->beginPrimaryChanges( $this->trxRoundFname ); // set DBO_TRX
739        }
740
741        $lb->setTableAliases( $this->tableAliases );
742        $lb->setDomainAliases( $this->domainAliases );
743    }
744
745    /** @inheritDoc */
746    public function setTableAliases( array $aliases ) {
747        $this->tableAliases = $aliases;
748    }
749
750    /** @inheritDoc */
751    public function setDomainAliases( array $aliases ) {
752        $this->domainAliases = $aliases;
753    }
754
755    /** @inheritDoc */
756    public function getTransactionProfiler(): TransactionProfiler {
757        return $this->trxProfiler;
758    }
759
760    /** @inheritDoc */
761    public function setLocalDomainPrefix( $prefix ) {
762        $this->localDomain = new DatabaseDomain(
763            $this->localDomain->getDatabase(),
764            $this->localDomain->getSchema(),
765            $prefix
766        );
767
768        foreach ( $this->getLBsForOwner() as $lb ) {
769            $lb->setLocalDomainPrefix( $prefix );
770        }
771    }
772
773    /** @inheritDoc */
774    public function redefineLocalDomain( $domain ) {
775        $this->closeAll( __METHOD__ );
776
777        $this->localDomain = DatabaseDomain::newFromId( $domain );
778
779        foreach ( $this->getLBsForOwner() as $lb ) {
780            $lb->redefineLocalDomain( $this->localDomain );
781        }
782    }
783
784    /** @inheritDoc */
785    public function closeAll( $fname = __METHOD__ ) {
786        /** @noinspection PhpUnusedLocalVariableInspection */
787        $scope = ScopedCallback::newScopedIgnoreUserAbort();
788
789        foreach ( $this->getLBsForOwner() as $lb ) {
790            $lb->closeAll( $fname );
791        }
792    }
793
794    /** @inheritDoc */
795    public function setAgentName( $agent ) {
796        $this->agent = $agent;
797    }
798
799    /** @inheritDoc */
800    public function hasStreamingReplicaServers() {
801        foreach ( $this->getLBsForOwner() as $lb ) {
802            if ( $lb->hasStreamingReplicaServers() ) {
803                return true;
804            }
805        }
806        return false;
807    }
808
809    /** @inheritDoc */
810    public function setDefaultReplicationWaitTimeout( $seconds ) {
811        $old = $this->replicationWaitTimeout;
812        $this->replicationWaitTimeout = max( 1, (int)$seconds );
813
814        return $old;
815    }
816
817    /**
818     * @param string $stage
819     */
820    private function assertTransactionRoundStage( $stage ) {
821        if ( $this->trxRoundStage !== $stage ) {
822            $transactionName = $this->trxRoundFname ?? '<unknown>';
823            throw new DBTransactionError(
824                null,
825                "Transaction '$transactionName' round stage must be '$stage' (not '{$this->trxRoundStage}')"
826            );
827        }
828    }
829}