Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.55% covered (warning)
57.55%
507 / 881
37.66% covered (danger)
37.66%
29 / 77
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoadBalancer
57.55% covered (warning)
57.55%
507 / 881
37.66% covered (danger)
37.66%
29 / 77
7759.26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 configure
87.76% covered (warning)
87.76%
43 / 49
0.00% covered (danger)
0.00%
0 / 1
12.26
 newTrackedConnectionsArray
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getClusterName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLocalDomainID
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveDomainID
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveDomainInstance
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 sanitizeConnectionFlags
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 enforceConnectionFlags
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
6.00
 getRandomNonLagged
50.00% covered (danger)
50.00%
14 / 28
0.00% covered (danger)
0.00%
0 / 1
16.00
 shuffleSharding
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getReaderIndex
86.49% covered (warning)
86.49%
32 / 37
0.00% covered (danger)
0.00%
0 / 1
17.71
 getExistingReaderIndex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pickReaderIndex
62.96% covered (warning)
62.96%
17 / 27
0.00% covered (danger)
0.00%
0 / 1
15.08
 waitForAll
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
90
 getAnyOpenConnection
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
7.01
 pickAnyOpenConnection
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
6.60
 awaitSessionPrimaryPos
7.14% covered (danger)
7.14%
3 / 42
0.00% covered (danger)
0.00%
0 / 1
127.29
 getConnection
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 getConnectionInternal
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 getServerConnection
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
7.89
 getMaintenanceConnectionRef
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 reuseOrOpenConnectionForNewRef
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
15.09
 assertConnectionDomain
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 getServerAttributes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 reallyOpenConnection
80.00% covered (warning)
80.00%
52 / 65
0.00% covered (danger)
0.00%
0 / 1
14.35
 loadSessionPrimaryPos
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
6.84
 reportConnectionError
30.30% covered (danger)
30.30%
10 / 33
0.00% covered (danger)
0.00%
0 / 1
23.59
 getServerCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasReplicaServers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasStreamingReplicaServers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getServerName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getServerInfo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getServerType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrimaryPos
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 reconfigure
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 disable
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 closeAll
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 closeConnection
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 finalizePrimaryChanges
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 approvePrimaryChanges
28.57% covered (danger)
28.57%
10 / 35
0.00% covered (danger)
0.00%
0 / 1
46.44
 beginPrimaryChanges
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
3.26
 commitPrimaryChanges
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
6.47
 runPrimaryTransactionIdleCallbacks
47.62% covered (danger)
47.62%
20 / 42
0.00% covered (danger)
0.00%
0 / 1
24.37
 runPrimaryTransactionListenerCallbacks
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
5.02
 rollbackPrimaryChanges
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 flushPrimarySessions
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 assertTransactionRoundStage
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
4.19
 syncConnectionRoundState
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 flushReplicaSnapshots
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 flushPrimarySnapshots
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hasPrimaryConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasPrimaryChanges
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 lastPrimaryChangeTimestamp
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 hasOrMadeRecentPrimaryChanges
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 pendingPrimaryChangeCallers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 explicitTrxActive
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 setLaggedReplicaMode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 laggedReplicaUsed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getReadOnlyReason
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isPrimaryRunningReadOnly
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
4.02
 pingAll
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getOpenConnections
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getOpenPrimaryConnections
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getMaxLag
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 getLagTimes
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
4.01
 waitForPrimaryPos
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
72
 setTransactionListener
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 setTableAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDomainAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLocalDomainPrefix
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 redefineLocalDomain
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setTempTablesOnlyMode
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 stringifyConn
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fieldHasBit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConnLogContext
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 setMockTime
n/a
0 / 0
n/a
0 / 0
2
 setDefaultGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\Rdbms;
7
8use InvalidArgumentException;
9use LogicException;
10use Psr\Log\LoggerInterface;
11use Psr\Log\NullLogger;
12use RuntimeException;
13use Throwable;
14use UnexpectedValueException;
15use Wikimedia\ArrayUtils\ArrayUtils;
16use Wikimedia\ObjectCache\BagOStuff;
17use Wikimedia\ObjectCache\EmptyBagOStuff;
18use Wikimedia\ObjectCache\WANObjectCache;
19use Wikimedia\ScopedCallback;
20use Wikimedia\Stats\StatsFactory;
21use Wikimedia\Timestamp\ConvertibleTimestamp;
22
23/**
24 * @see ILoadBalancer
25 * @ingroup Database
26 */
27class LoadBalancer implements ILoadBalancerForOwner {
28    /** @var ILoadMonitor */
29    private $loadMonitor;
30    /** @var BagOStuff */
31    private $srvCache;
32    /** @var WANObjectCache */
33    private $wanCache;
34    /** @var DatabaseFactory */
35    private $databaseFactory;
36
37    /** @var TransactionProfiler */
38    private $trxProfiler;
39    /** @var StatsFactory */
40    private $statsFactory;
41    /** @var LoggerInterface */
42    private $logger;
43    /** @var callable Exception logger */
44    private $errorLogger;
45    /** @var DatabaseDomain Local DB domain ID and default for new connections */
46    private $localDomain;
47    /** @var bool Whether this PHP instance is for a CLI script */
48    private $cliMode;
49
50    /** @var array<string,array<int,Database[]>> Map of (connection pool => server index => Database[]) */
51    private $conns;
52
53    /** @var string The name of the DB cluster */
54    private $clusterName;
55    /** @var ServerInfo */
56    private $serverInfo;
57    /** @var array<int,int|float> Map of server index => weight */
58    private $loads;
59    /** @var string|null Default query group to use with getConnection() */
60    private $defaultGroup;
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    private $domainAliases = [];
66    /** @var callable[] Map of (name => callable) */
67    private $trxRecurringCallbacks = [];
68    /** @var bool[] Map of (domain => whether to use "temp tables only" mode) */
69    private $tempTablesOnlyMode = [];
70
71    /** @var string|null Active explicit transaction round owner or false if none */
72    private $trxRoundFname = null;
73    /** @var string Stage of the current transaction round in the transaction round life-cycle */
74    private $trxRoundStage = self::ROUND_CURSORY;
75    /** @var int[] The group replica server indexes keyed by group */
76    private $readIndexByGroup = [];
77    /** @var DBPrimaryPos|null Replication sync position or false if not set */
78    private $waitForPos;
79    /** @var bool Whether a lagged replica DB was used */
80    private $laggedReplicaMode = false;
81    /** @var string|false Reason this instance is read-only or false if not */
82    private $readOnlyReason = false;
83    /** @var int Total number of new connections ever made with this instance */
84    private $connectionCounter = 0;
85    /** @var bool */
86    private $disabled = false;
87    private ?ChronologyProtector $chronologyProtector = null;
88    /** @var bool Whether the session consistency callback already executed */
89    private $chronologyProtectorCalled = false;
90
91    /** @var IDatabaseForOwner|null The last connection handle that caused a problem */
92    private $lastErrorConn;
93
94    /** @var DatabaseDomain[] Map of (domain ID => domain instance) */
95    private $nonLocalDomainCache = [];
96    private ?string $uniqueIdentifier = null;
97
98    /**
99     * @var int Modification counter for invalidating connections held by
100     *      DBConnRef instances. This is bumped by reconfigure().
101     */
102    private $modcount = 0;
103
104    /** The "server index" LB info key; see {@link IDatabase::getLBInfo()} */
105    private const INFO_SERVER_INDEX = 'serverIndex';
106    /** The "connection category" LB info key; see {@link IDatabase::getLBInfo()} */
107    private const INFO_CONN_CATEGORY = 'connCategory';
108
109    /** Warn when this many connection are held */
110    private const CONN_HELD_WARN_THRESHOLD = 10;
111
112    /** Default 'waitTimeout' when unspecified */
113    private const MAX_WAIT_DEFAULT = 10;
114    /** Seconds to cache primary DB server read-only status */
115    private const TTL_CACHE_READONLY = 5;
116
117    /** A category of connections that are tracked and transaction round aware */
118    private const CATEGORY_ROUND = 'round';
119    /** A category of connections that are tracked and in autocommit-mode */
120    private const CATEGORY_AUTOCOMMIT = 'auto-commit';
121    /** A category of connections that are untracked and in gauge-mode */
122    private const CATEGORY_GAUGE = 'gauge';
123
124    /** Transaction round, explicit or implicit, has not finished writing */
125    private const ROUND_CURSORY = 'cursory';
126    /** Transaction round writes are complete and ready for pre-commit checks */
127    private const ROUND_FINALIZED = 'finalized';
128    /** Transaction round passed final pre-commit checks */
129    private const ROUND_APPROVED = 'approved';
130    /** Transaction round was committed and post-commit callbacks must be run */
131    private const ROUND_COMMIT_CALLBACKS = 'commit-callbacks';
132    /** Transaction round was rolled back and post-rollback callbacks must be run */
133    private const ROUND_ROLLBACK_CALLBACKS = 'rollback-callbacks';
134    /** Transaction round encountered an error */
135    private const ROUND_ERROR = 'error';
136
137    /** @var int Idiom for getExistingReaderIndex() meaning "no index selected" */
138    private const READER_INDEX_NONE = -1;
139
140    public function __construct( array $params ) {
141        $this->configure( $params );
142
143        $this->conns = self::newTrackedConnectionsArray();
144    }
145
146    /**
147     * @param array $params A database configuration array, see $wgLBFactoryConf.
148     *
149     * @return void
150     */
151    protected function configure( array $params ): void {
152        $this->localDomain = isset( $params['localDomain'] )
153            ? DatabaseDomain::newFromId( $params['localDomain'] )
154            : DatabaseDomain::newUnspecified();
155
156        $this->serverInfo = new ServerInfo();
157        $this->loads = [];
158        foreach ( $this->serverInfo->normalizeServerMaps( $params['servers'] ?? [] ) as $i => $server ) {
159            $this->serverInfo->addServer( $i, $server );
160            $this->loads[$i] = $server['load'];
161        }
162        // If the cluster name is not specified, fallback to the current primary name
163        $this->clusterName = $params['clusterName']
164            ?? $this->serverInfo->getServerName( ServerInfo::WRITER_INDEX );
165
166        if ( isset( $params['readOnlyReason'] ) && is_string( $params['readOnlyReason'] ) ) {
167            $this->readOnlyReason = $params['readOnlyReason'];
168        }
169
170        $this->srvCache = $params['srvCache'] ?? new EmptyBagOStuff();
171        $this->wanCache = $params['wanCache'] ?? WANObjectCache::newEmpty();
172
173        // Note: this parameter is normally absent. It is injectable for testing purposes only.
174        $this->databaseFactory = $params['databaseFactory'] ?? new DatabaseFactory( $params );
175
176        $this->errorLogger = $params['errorLogger'] ?? static function ( Throwable $e ) {
177            trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
178        };
179        $this->logger = $params['logger'] ?? new NullLogger();
180
181        $this->trxProfiler = $params['trxProfiler'] ?? new TransactionProfiler();
182        $this->statsFactory = $params['statsFactory'] ?? StatsFactory::newNull();
183
184        // Set up LoadMonitor
185        $loadMonitorConfig = $params['loadMonitor'] ?? [ 'class' => LoadMonitorNull::class ];
186        $compat = [
187            'LoadMonitor' => LoadMonitor::class,
188            'LoadMonitorNull' => LoadMonitorNull::class
189        ];
190        $class = $loadMonitorConfig['class'];
191        // @phan-suppress-next-line PhanImpossibleCondition
192        if ( isset( $compat[$class] ) ) {
193            $class = $compat[$class];
194        }
195        $this->loadMonitor = new $class(
196            $this,
197            $this->srvCache,
198            $this->wanCache,
199            $this->logger,
200            $this->statsFactory,
201            $loadMonitorConfig
202        );
203
204        if ( isset( $params['chronologyProtector'] ) ) {
205            $this->chronologyProtector = $params['chronologyProtector'];
206        }
207
208        if ( isset( $params['roundStage'] ) ) {
209            if ( $params['roundStage'] === self::STAGE_POSTCOMMIT_CALLBACKS ) {
210                $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS;
211            } elseif ( $params['roundStage'] === self::STAGE_POSTROLLBACK_CALLBACKS ) {
212                $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS;
213            }
214        }
215
216        $this->cliMode = $params['cliMode'] ?? ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' );
217
218        $this->defaultGroup = $params['defaultGroup'] ?? self::GROUP_GENERIC;
219        if ( empty( $params['shuffleSharding'] ) ) {
220            $this->uniqueIdentifier = null;
221        } else {
222            $this->uniqueIdentifier = $params['uniqueIdentifier'] ?? null;
223        }
224    }
225
226    private static function newTrackedConnectionsArray(): array {
227        // Note that CATEGORY_GAUGE connections are untracked
228        return [
229            self::CATEGORY_ROUND => [],
230            self::CATEGORY_AUTOCOMMIT => []
231        ];
232    }
233
234    public function getClusterName(): string {
235        return $this->clusterName;
236    }
237
238    public function getLocalDomainID(): string {
239        return $this->localDomain->getId();
240    }
241
242    /** @inheritDoc */
243    public function resolveDomainID( DatabaseDomain|string|false $domain ): string {
244        return $this->resolveDomainInstance( $domain )->getId();
245    }
246
247    final protected function resolveDomainInstance( DatabaseDomain|string|false $domain ): DatabaseDomain {
248        if ( $domain instanceof DatabaseDomain ) {
249            return $domain; // already a domain instance
250        } elseif ( $domain === false || $domain === $this->localDomain->getId() ) {
251            return $this->localDomain;
252        } elseif ( isset( $this->domainAliases[$domain] ) ) {
253            $this->domainAliases[$domain] =
254                DatabaseDomain::newFromId( $this->domainAliases[$domain] );
255
256            return $this->domainAliases[$domain];
257        }
258
259        $cachedDomain = $this->nonLocalDomainCache[$domain] ?? null;
260        if ( $cachedDomain === null ) {
261            $cachedDomain = DatabaseDomain::newFromId( $domain );
262            $this->nonLocalDomainCache = [ $domain => $cachedDomain ];
263        }
264
265        return $cachedDomain;
266    }
267
268    /**
269     * Sanitize connection flags provided by a call to getConnection()
270     *
271     * @param int $flags Bitfield of class CONN_* constants
272     * @param string $domain Database domain
273     * @return int Sanitized bitfield
274     */
275    protected function sanitizeConnectionFlags( $flags, $domain ) {
276        if ( self::fieldHasBit( $flags, self::CONN_TRX_AUTOCOMMIT ) ) {
277            // Callers use CONN_TRX_AUTOCOMMIT to bypass REPEATABLE-READ staleness without
278            // resorting to row locks (e.g. FOR UPDATE) or to make small out-of-band commits
279            // during larger transactions. This is useful for avoiding lock contention.
280            // Assuming all servers are of the same type (or similar), which is overwhelmingly
281            // the case, use the primary server information to get the attributes. The information
282            // for $i cannot be used since it might be DB_REPLICA, which might require connection
283            // attempts in order to be resolved into a real server index.
284            $attributes = $this->getServerAttributes( ServerInfo::WRITER_INDEX );
285            if ( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ) {
286                // The RDBMS does not support concurrent writes (e.g. SQLite), so attempts
287                // to use separate connections would just cause self-deadlocks. Note that
288                // REPEATABLE-READ staleness is not an issue since DB-level locking means
289                // that transactions are Strict Serializable anyway.
290                $flags &= ~self::CONN_TRX_AUTOCOMMIT;
291                $type = $this->serverInfo->getServerType( ServerInfo::WRITER_INDEX );
292                $this->logger->info( __METHOD__ . ": CONN_TRX_AUTOCOMMIT disallowed ($type)" );
293            } elseif ( isset( $this->tempTablesOnlyMode[$domain] ) ) {
294                // T202116: integration tests are active and queries should be all be using
295                // temporary clone tables (via prefix). Such tables are not visible across
296                // different connections nor can there be REPEATABLE-READ snapshot staleness,
297                // so use the same connection for everything.
298                $flags &= ~self::CONN_TRX_AUTOCOMMIT;
299            }
300        }
301
302        return $flags;
303    }
304
305    /**
306     * @param IDatabase $conn
307     * @param int $flags
308     * @throws DBUnexpectedError
309     */
310    private function enforceConnectionFlags( IDatabase $conn, $flags ) {
311        if (
312            self::fieldHasBit( $flags, self::CONN_TRX_AUTOCOMMIT ) ||
313            // Handles with open transactions are avoided since they might be subject
314            // to REPEATABLE-READ snapshots, which could affect the lag estimate query.
315            self::fieldHasBit( $flags, self::CONN_UNTRACKED_GAUGE )
316        ) {
317            if ( $conn->trxLevel() ) {
318                throw new DBUnexpectedError(
319                    $conn,
320                    'Handle requested with autocommit-mode yet it has a transaction'
321                );
322            }
323
324            $conn->clearFlag( $conn::DBO_TRX ); // auto-commit mode
325        }
326    }
327
328    /**
329     * @param array<int,int|float> $loads Map of (server index => weight) for a load group
330     * @param int|float $sessionLagLimit Additional maximum lag threshold imposed by the session;
331     *  use INF if none applies. Servers will count as lagged if their lag exceeds either this
332     *  value or the configured "max lag" value.
333     * @return int|false Index of a non-lagged server with non-zero weight; false if none
334     */
335    private function getRandomNonLagged( array $loads, $sessionLagLimit = INF ) {
336        $lags = $this->getLagTimes();
337
338        $dbs = [];
339        foreach ( $loads as $i => $load ) {
340            $srvName = $this->serverInfo->getServerName( $i );
341            $dbs[$srvName] = $i;
342        }
343        // Unset excessively lagged servers from the load group
344        foreach ( $lags as $i => $lag ) {
345            if ( $i !== ServerInfo::WRITER_INDEX ) {
346                // How much lag normally counts as "excessive" for this server
347                $maxServerLag = $this->serverInfo->getServerMaxLag( $i );
348                // How much lag counts as "excessive" for this server given the session
349                $maxServerLag = min( $maxServerLag, $sessionLagLimit );
350
351                $srvName = $this->serverInfo->getServerName( $i );
352                if ( $lag === false && !is_infinite( $maxServerLag ) ) {
353                    $this->logger->debug(
354                        __METHOD__ . ": server {db_server} is not replicating?",
355                        [ 'db_server' => $srvName ]
356                    );
357                    unset( $loads[$i] );
358                    unset( $dbs[$srvName] );
359                } elseif ( $lag > $maxServerLag ) {
360                    $this->logger->debug(
361                        __METHOD__ .
362                            ": server {db_server} has {lag} seconds of lag (>= {maxlag})",
363                        [ 'db_server' => $srvName, 'lag' => $lag, 'maxlag' => $maxServerLag ]
364                    );
365                    unset( $loads[$i] );
366                    unset( $dbs[$srvName] );
367                }
368            }
369        }
370
371        if ( array_sum( $loads ) == 0 ) {
372            // All the replicas with non-zero weight are lagged and the primary has zero load.
373            // Inform caller so that it can use switch to read-only mode and use a lagged replica.
374            return false;
375        }
376
377        // Return a server index based on weighted random selection
378        return $this->shuffleSharding( $dbs, $loads );
379    }
380
381    /**
382     * Apply shuffle sharding. Idea originally from Amazon's Route 53.
383     *
384     * It basically used a unique identifier from the request (IP is good enough) and uses hashing
385     * to pick a consistent set of 2 or 3 from the pool. Then using randomness, picks one at random.
386     *
387     * It provides many benefits and makes the system more robust against issues or attacks.
388     *
389     * @param array $dbs Map of server name => server index
390     * @param array $loads Map of (server index => weight) for a load group
391     * @return int Index of the server
392     */
393    private function shuffleSharding( $dbs, $loads ) {
394        // shuffle sharding doesn't make sense in small groups
395        if ( count( $loads ) <= 3 || $this->uniqueIdentifier === null ) {
396            return ArrayUtils::pickRandom( $loads );
397        }
398
399        ArrayUtils::consistentHashSort( $dbs, $this->uniqueIdentifier );
400        $keys = array_values( $dbs );
401        return ArrayUtils::pickRandom( [
402            $keys[0] => $loads[$keys[0]],
403            $keys[1] => $loads[$keys[1]],
404            $keys[2] => $loads[$keys[2]],
405        ] );
406    }
407
408    /** @inheritDoc */
409    public function getReaderIndex( $group = false ) {
410        if ( !$this->serverInfo->hasReplicaServers() ) {
411            // There is only one possible server to use (the primary)
412            return ServerInfo::WRITER_INDEX;
413        }
414
415        if (
416            $group === 'dump' ||
417            $group === 'vslow' ||
418            $group === [ 'dump' ] ||
419            $group === [ 'vslow' ] ||
420            $this->defaultGroup === 'dump' ||
421            $this->defaultGroup === 'vslow'
422        ) {
423            $group = 'vslow';
424        } else {
425            $group = self::GROUP_GENERIC;
426        }
427
428        $index = $this->getExistingReaderIndex( $group );
429        if ( $index !== self::READER_INDEX_NONE ) {
430            // A reader index was already selected for this query group. Keep using it,
431            // since any session replication position was already waited on and any
432            // active transaction will be reused (e.g. for point-in-time snapshots).
433            return $index;
434        }
435
436        // Get the server weight array for this load group
437        if ( $group === 'vslow' ) {
438            // pick the replica with the lowest weight above zero as the vslow
439            $lowestWeight = INF;
440            $loads = $this->loads;
441            foreach ( $this->loads as $index => $weight ) {
442                if ( $weight > 0 && $weight < $lowestWeight ) {
443                    $loads = [ $index => $weight ];
444                    $lowestWeight = $weight;
445                }
446            }
447        } else {
448            $loads = $this->loads;
449        }
450
451        if ( !$loads ) {
452            $this->logger->info( __METHOD__ . ": no loads for group $group" );
453            return false;
454        }
455
456        // Load any session replication positions, before any connection attempts,
457        // since reading them afterwards can only cause more delay due to possibly
458        // seeing even higher replication positions (e.g. from concurrent requests).
459        $this->loadSessionPrimaryPos();
460
461        // Scale the configured load weights according to each server's load/state.
462        // This can sometimes trigger server connections due to cache regeneration.
463        $this->loadMonitor->scaleLoads( $loads );
464
465        // Pick a server, accounting for load weights, lag, and session consistency
466        $i = $this->pickReaderIndex( $loads );
467        if ( $i === false ) {
468            // Connection attempts failed
469            return false;
470        }
471
472        // If data seen by queries is expected to reflect writes from a prior transaction,
473        // then wait for the chosen server to apply those changes. This is used to improve
474        // session consistency.
475        if ( !$this->awaitSessionPrimaryPos( $i ) ) {
476            // Data will be outdated compared to what was expected
477            $this->setLaggedReplicaMode();
478        }
479
480        // Keep using this server for DB_REPLICA handles for this group
481        if ( $i < 0 ) {
482            throw new UnexpectedValueException( "Cannot set a negative read server index" );
483        }
484        $this->readIndexByGroup[$group] = $i;
485
486        $serverName = $this->getServerName( $i );
487        $this->logger->debug( __METHOD__ . ": using server $serverName for group '$group'" );
488
489        return $i;
490    }
491
492    /**
493     * Get the server index chosen for DB_REPLICA connections for the given query group
494     *
495     * @param string $group Query group; use false for the generic group
496     * @return int Specific server index or LoadBalancer::READER_INDEX_NONE if none was chosen
497     */
498    protected function getExistingReaderIndex( $group ) {
499        return $this->readIndexByGroup[$group] ?? self::READER_INDEX_NONE;
500    }
501
502    /**
503     * Pick a server that is reachable and preferably non-lagged based on the load weights
504     *
505     * This will leave the server connection open within the pool for reuse
506     *
507     * @param array<int,int|float> $loads Map of (server index => weight) for a load group
508     * @return int|false Server index to use as the load group reader index; false on failure
509     */
510    private function pickReaderIndex( array $loads ) {
511        if ( $loads === [] ) {
512            throw new InvalidArgumentException( "Load group array is empty" );
513        }
514
515        $i = false;
516        // Quickly look through the available servers for a server that meets criteria...
517        $currentLoads = $loads;
518        while ( count( $currentLoads ) ) {
519            if ( $this->waitForPos && $this->waitForPos->asOfTime() ) {
520                $this->logger->debug( __METHOD__ . ": session has replication position" );
521                // ChronologyProtector::getSessionPrimaryPos called in loadSessionPrimaryPos()
522                // sets "waitForPos" for session consistency. This triggers doWait() after
523                // connect, so it's especially good to avoid lagged servers so as to avoid
524                // excessive delay in that method.
525                $ago = microtime( true ) - $this->waitForPos->asOfTime();
526                // Aim for <= 1 second of waiting (being too picky can backfire)
527                $i = $this->getRandomNonLagged( $currentLoads, $ago + 1 );
528            } else {
529                // Any server with less lag than it's 'max lag' param is preferable
530                $i = $this->getRandomNonLagged( $currentLoads );
531            }
532
533            if ( $i === false && count( $currentLoads ) ) {
534                $this->setLaggedReplicaMode();
535                // All replica DBs lagged, just pick anything.
536                $i = ArrayUtils::pickRandom( $currentLoads );
537            }
538
539            if ( $i === false ) {
540                // pickRandom() returned false.
541                // This is permanent and means the configuration or LoadMonitor
542                // wants us to return false.
543                $this->logger->debug( __METHOD__ . ": no suitable server found" );
544                return false;
545            }
546
547            $serverName = $this->getServerName( $i );
548            $this->logger->debug( __METHOD__ . ": connecting to $serverName..." );
549
550            // Get a connection to this server without triggering complementary connections
551            // to other servers (due to things like lag or read-only checks). We want to avoid
552            // the risk of overhead and recursion here.
553            $conn = $this->getServerConnection( $i, self::DOMAIN_ANY, self::CONN_SILENCE_ERRORS );
554            if ( !$conn ) {
555                $this->logger->warning( __METHOD__ . ": failed connecting to $serverName" );
556                unset( $currentLoads[$i] ); // avoid this server next iteration
557                continue;
558            }
559
560            // Return this server
561            break;
562        }
563
564        // If all servers were down, quit now
565        if ( $currentLoads === [] ) {
566            $this->logger->error( __METHOD__ . ": all servers down" );
567        }
568
569        return $i;
570    }
571
572    /** @inheritDoc */
573    public function waitForAll( DBPrimaryPos $pos, $timeout = null ) {
574        $timeout = $timeout ?: self::MAX_WAIT_DEFAULT;
575
576        $oldPos = $this->waitForPos;
577        try {
578            $this->waitForPos = $pos;
579
580            $failedReplicas = [];
581            foreach ( $this->serverInfo->getStreamingReplicaIndexes() as $i ) {
582                if ( isset( $this->loads[$i] ) && $this->loads[$i] > 0 ) {
583                    $start = ConvertibleTimestamp::hrtime();
584                    $ok = $this->awaitSessionPrimaryPos( $i, $timeout );
585                    if ( !$ok ) {
586                        $failedReplicas[] = $this->getServerName( $i );
587                    }
588                    $timeout -= intval( ( ConvertibleTimestamp::hrtime() - $start ) / 1e9 );
589                }
590            }
591
592            // Stop spamming logs when only one replica is lagging and we have 5+ replicas.
593            // Mediawiki automatically stops sending queries to the lagged one.
594            $failed = $failedReplicas && ( count( $failedReplicas ) > 1 || $this->getServerCount() < 5 );
595            if ( $failed ) {
596                $this->logger->error(
597                    "Timed out waiting for replication to reach {raw_pos}",
598                    [
599                        'raw_pos' => $pos->__toString(),
600                        'failed_hosts' => $failedReplicas,
601                        'timeout' => $timeout,
602                        'exception' => new RuntimeException()
603                    ]
604                );
605            }
606
607            return !$failed;
608        } finally {
609            // Restore the old position; this is used for throttling, not lag-protection
610            $this->waitForPos = $oldPos;
611        }
612    }
613
614    /**
615     * @param int $i
616     * @param int $flags
617     * @return IDatabaseForOwner|false
618     */
619    public function getAnyOpenConnection( $i, $flags = 0 ) {
620        $i = ( $i === self::DB_PRIMARY ) ? ServerInfo::WRITER_INDEX : $i;
621        $conn = false;
622        foreach ( $this->conns as $type => $poolConnsByServer ) {
623            if ( $i === self::DB_REPLICA ) {
624                // Consider all existing connections to any server
625                $applicableConnsByServer = $poolConnsByServer;
626            } else {
627                // Consider all existing connections to a specific server
628                $applicableConnsByServer = isset( $poolConnsByServer[$i] )
629                    ? [ $i => $poolConnsByServer[$i] ]
630                    : [];
631            }
632
633            $conn = $this->pickAnyOpenConnection( $applicableConnsByServer );
634            if ( $conn ) {
635                $this->logger->debug( __METHOD__ . ": found '$type' connection to #$i." );
636                break;
637            }
638        }
639
640        if ( $conn ) {
641            $this->enforceConnectionFlags( $conn, $flags );
642        }
643
644        return $conn;
645    }
646
647    /**
648     * @param Database[][] $connsByServer Map of (server index => array of DB handles)
649     * @return IDatabaseForOwner|false An appropriate open connection or false if none found
650     */
651    private function pickAnyOpenConnection( array $connsByServer ) {
652        foreach ( $connsByServer as $i => $conns ) {
653            foreach ( $conns as $conn ) {
654                if ( !$conn->isOpen() ) {
655                    $this->logger->warning(
656                        __METHOD__ .
657                        ": pooled DB handle for {db_server} (#$i) has no open connection.",
658                        $this->getConnLogContext( $conn )
659                    );
660                    continue; // some sort of error occurred?
661                }
662                return $conn;
663            }
664        }
665
666        return false;
667    }
668
669    /**
670     * Wait for a given replica DB to catch up to the primary DB pos stored in "waitForPos"
671     *
672     * @see loadSessionPrimaryPos()
673     *
674     * @param int $index Specific server index
675     * @param int|null $timeout Max seconds to wait; default is "waitTimeout"
676     * @return bool Success
677     */
678    private function awaitSessionPrimaryPos( $index, $timeout = null ) {
679        $timeout = max( 1, intval( $timeout ?: self::MAX_WAIT_DEFAULT ) );
680
681        if ( !$this->waitForPos || $index === ServerInfo::WRITER_INDEX ) {
682            return true;
683        }
684
685        $srvName = $this->getServerName( $index );
686
687        // Check if we already know that the DB has reached this point
688        $key = $this->srvCache->makeGlobalKey( __CLASS__, 'last-known-pos', $srvName, 'v2' );
689
690        /** @var DBPrimaryPos $knownReachedPos */
691        $position = $this->srvCache->get( $key );
692        if ( !is_array( $position ) ) {
693            $knownReachedPos = null;
694        } else {
695            $class = $position['_type_'];
696            $knownReachedPos = $class::newFromArray( $position );
697        }
698        if (
699            $knownReachedPos instanceof DBPrimaryPos &&
700            $knownReachedPos->hasReached( $this->waitForPos )
701        ) {
702            $this->logger->debug(
703                __METHOD__ .
704                ": replica DB {db_server} known to be caught up (pos >= $knownReachedPos).",
705                [ 'db_server' => $srvName ]
706            );
707
708            return true;
709        }
710
711        $close = false; // close the connection afterwards
712        $flags = self::CONN_SILENCE_ERRORS;
713        // Check if there is an existing connection that can be used
714        $conn = $this->getAnyOpenConnection( $index, $flags );
715        if ( !$conn ) {
716            // Get a connection to this server without triggering complementary connections
717            // to other servers (due to things like lag or read-only checks). We want to avoid
718            // the risk of overhead and recursion here.
719            $conn = $this->getServerConnection( $index, self::DOMAIN_ANY, $flags );
720            if ( !$conn ) {
721                $this->logger->warning(
722                    __METHOD__ . ': failed to connect to {db_server}',
723                    [ 'db_server' => $srvName ]
724                );
725
726                return false;
727            }
728            // Avoid connection spam in waitForAll() when connections
729            // are made just for the sake of doing this lag check.
730            $close = true;
731        }
732
733        $this->logger->info(
734            __METHOD__ .
735            ': waiting for replica DB {db_server} to catch up...',
736            $this->getConnLogContext( $conn )
737        );
738
739        $result = $conn->primaryPosWait( $this->waitForPos, $timeout );
740
741        $ok = ( $result !== null && $result != -1 );
742        if ( $ok ) {
743            // Remember that the DB reached this point
744            $this->srvCache->set( $key, $this->waitForPos->toArray(), BagOStuff::TTL_DAY );
745        }
746
747        if ( $close ) {
748            $this->closeConnection( $conn );
749        }
750
751        return $ok;
752    }
753
754    /** @inheritDoc */
755    public function getConnection( $i, $groups = [], string|false $domain = false, $flags = 0 ) {
756        if ( self::fieldHasBit( $flags, self::CONN_SILENCE_ERRORS ) ) {
757            throw new UnexpectedValueException(
758                __METHOD__ . ' got CONN_SILENCE_ERRORS; connection is already deferred'
759            );
760        }
761
762        $domain = $this->resolveDomainID( $domain );
763        $role = ( $i === self::DB_PRIMARY || $i === ServerInfo::WRITER_INDEX )
764            ? self::DB_PRIMARY
765            : self::DB_REPLICA;
766
767        return new DBConnRef( $this, [ $i, $groups, $domain, $flags ], $role, $this->modcount );
768    }
769
770    /** @inheritDoc */
771    public function getConnectionInternal( $i, $groups = [], $domain = false, $flags = 0 ): IDatabase {
772        $domain = $this->resolveDomainID( $domain );
773        $flags = $this->sanitizeConnectionFlags( $flags, $domain );
774        // If given DB_PRIMARY/DB_REPLICA, resolve it to a specific server index. Resolving
775        // DB_REPLICA might trigger getServerConnection() calls due to the getReaderIndex()
776        // connectivity checks or LoadMonitor::scaleLoads() server state cache regeneration.
777        // The use of getServerConnection() instead of getConnection() avoids infinite loops.
778        $serverIndex = $i;
779        if ( $i === self::DB_PRIMARY ) {
780            $serverIndex = ServerInfo::WRITER_INDEX;
781        } elseif ( $i === self::DB_REPLICA ) {
782            $groupIndex = $this->getReaderIndex( $groups );
783            if ( $groupIndex !== false ) {
784                // Group connection succeeded
785                $serverIndex = $groupIndex;
786            }
787            if ( $serverIndex < 0 ) {
788                $this->reportConnectionError( 'could not connect to any replica DB server' );
789            }
790        } elseif ( !$this->serverInfo->hasServerIndex( $i ) ) {
791            throw new UnexpectedValueException( "Invalid server index index #$i" );
792        }
793        // Get an open connection to that server (might trigger a new connection)
794        return $this->getServerConnection( $serverIndex, $domain, $flags );
795    }
796
797    /** @inheritDoc */
798    public function getServerConnection( $i, $domain, $flags = 0 ) {
799        $domainInstance = DatabaseDomain::newFromId( $domain );
800        // Number of connections made before getting the server index and handle
801        $priorConnectionsMade = $this->connectionCounter;
802        // Get an open connection to this server (might trigger a new connection)
803        $conn = $this->reuseOrOpenConnectionForNewRef( $i, $domainInstance, $flags );
804        // Throw an error or otherwise bail out if the connection attempt failed
805        if ( !( $conn instanceof IDatabaseForOwner ) ) {
806            if ( !self::fieldHasBit( $flags, self::CONN_SILENCE_ERRORS ) ) {
807                $this->reportConnectionError();
808            }
809
810            return false;
811        }
812
813        // Profile any new connections caused by this method
814        if ( $this->connectionCounter > $priorConnectionsMade ) {
815            $this->trxProfiler->recordConnection(
816                $conn->getServerName(),
817                $conn->getDBname(),
818                ( $i === ServerInfo::WRITER_INDEX && $this->hasStreamingReplicaServers() )
819            );
820        }
821
822        if ( !$conn->isOpen() ) {
823            $this->lastErrorConn = $conn;
824            // Connection was made but later unrecoverably lost for some reason.
825            // Do not return a handle that will just throw exceptions on use, but
826            // let the calling code, e.g. getReaderIndex(), try another server.
827            if ( !self::fieldHasBit( $flags, self::CONN_SILENCE_ERRORS ) ) {
828                $this->reportConnectionError();
829            }
830            return false;
831        }
832
833        return $conn;
834    }
835
836    /** @inheritDoc */
837    public function getMaintenanceConnectionRef(
838        $i,
839        $groups = [],
840        $domain = false,
841        $flags = 0
842    ): DBConnRef {
843        if ( self::fieldHasBit( $flags, self::CONN_SILENCE_ERRORS ) ) {
844            throw new UnexpectedValueException(
845                __METHOD__ . ' CONN_SILENCE_ERRORS is not supported'
846            );
847        }
848
849        $domain = $this->resolveDomainID( $domain );
850        $role = ( $i === self::DB_PRIMARY || $i === ServerInfo::WRITER_INDEX )
851            ? self::DB_PRIMARY
852            : self::DB_REPLICA;
853
854        return new DBConnRef( $this, [ $i, $groups, $domain, $flags ], $role, $this->modcount );
855    }
856
857    /**
858     * Get a live connection handle to the given domain
859     *
860     * This will reuse an existing tracked connection when possible. In some cases, this
861     * involves switching the DB domain of an existing handle in order to reuse it. If no
862     * existing handles can be reused, then a new connection will be made.
863     *
864     * @param int $i Specific server index
865     * @param DatabaseDomain $domain Database domain ID required by the reference
866     * @param int $flags Bit field of class CONN_* constants
867     * @return IDatabase|null Database or null on error
868     * @throws DBError When database selection fails
869     * @throws InvalidArgumentException When the server index is invalid
870     * @throws UnexpectedValueException When the DB domain of the connection is corrupted
871     * @throws DBAccessError If disable() was called
872     */
873    private function reuseOrOpenConnectionForNewRef( $i, DatabaseDomain $domain, $flags = 0 ) {
874        // Figure out which connection pool to use based on the flags
875        if ( $this->fieldHasBit( $flags, self::CONN_UNTRACKED_GAUGE ) ) {
876            // Use low timeouts, use autocommit mode, ignore transaction rounds
877            $category = self::CATEGORY_GAUGE;
878        } elseif ( self::fieldHasBit( $flags, self::CONN_TRX_AUTOCOMMIT ) ) {
879            // Use autocommit mode, ignore transaction rounds
880            $category = self::CATEGORY_AUTOCOMMIT;
881        } else {
882            // Respect DBO_DEFAULT, respect transaction rounds
883            $category = self::CATEGORY_ROUND;
884        }
885
886        $conn = null;
887        // Reuse a free connection in the pool from any domain if possible. There should only
888        // be one connection in this pool unless either:
889        //  - a) IDatabase::databasesAreIndependent() returns true (e.g. postgres) and two
890        //       or more database domains have been used during the load balancer's lifetime
891        //  - b) Two or more nested function calls used getConnection() on different domains.
892        foreach ( ( $this->conns[$category][$i] ?? [] ) as $poolConn ) {
893            // Check if any required DB domain changes for the new reference are possible
894            // Calling selectDomain() would trigger a reconnect, which will break if a
895            // transaction is active or if there is any other meaningful session state.
896            $isShareable = !(
897                $poolConn->databasesAreIndependent() &&
898                $domain->getDatabase() !== null &&
899                $domain->getDatabase() !== $poolConn->getDBname()
900            );
901            if ( $isShareable ) {
902                $conn = $poolConn;
903                // Make any required DB domain changes for the new reference
904                if ( !$domain->isUnspecified() ) {
905                    $conn->selectDomain( $domain );
906                }
907                $this->logger->debug( __METHOD__ . ": reusing connection for $i/$domain" );
908                break;
909            }
910        }
911
912        // If necessary, try to open a new connection and add it to the pool
913        if ( !$conn ) {
914            $conn = $this->reallyOpenConnection(
915                $i,
916                $domain,
917                [ self::INFO_CONN_CATEGORY => $category ]
918            );
919            if ( $conn->isOpen() ) {
920                // Make Database::isReadOnly() respect server-side and configuration-based
921                // read-only mode. Note that replica handles are always seen as read-only
922                // in Database::isReadOnly() and Database::assertIsWritablePrimary().
923                if ( $i === ServerInfo::WRITER_INDEX ) {
924                    if ( $this->readOnlyReason !== false ) {
925                        $readOnlyReason = $this->readOnlyReason;
926                    } elseif ( $this->isPrimaryRunningReadOnly( $conn ) ) {
927                        $readOnlyReason = 'The primary database server is running in read-only mode.';
928                    } else {
929                        $readOnlyReason = false;
930                    }
931                    $conn->setLBInfo( $conn::LB_READ_ONLY_REASON, $readOnlyReason );
932                }
933                // Connection obtained; check if it belongs to a tracked connection category
934                if ( isset( $this->conns[$category] ) ) {
935                    // Track this connection for future reuse
936                    $this->conns[$category][$i][] = $conn;
937                }
938            } else {
939                $this->logger->warning( __METHOD__ . ": connection error for $i/$domain" );
940                $this->lastErrorConn = $conn;
941                $conn = null;
942            }
943        }
944
945        if ( $conn instanceof IDatabaseForOwner ) {
946            // Check to make sure that the right domain is selected
947            $this->assertConnectionDomain( $conn, $domain );
948            // Check to make sure that the CONN_* flags are respected
949            $this->enforceConnectionFlags( $conn, $flags );
950        }
951
952        return $conn;
953    }
954
955    /**
956     * Sanity check to make sure that the right domain is selected
957     *
958     * @param Database $conn
959     * @param DatabaseDomain $domain
960     * @throws DBUnexpectedError
961     */
962    private function assertConnectionDomain( Database $conn, DatabaseDomain $domain ) {
963        if ( !$domain->isCompatible( $conn->getDomainID() ) ) {
964            throw new UnexpectedValueException(
965                "Got connection to '{$conn->getDomainID()}', but expected one for '{$domain}'"
966            );
967        }
968    }
969
970    /** @inheritDoc */
971    public function getServerAttributes( $i ) {
972        return $this->databaseFactory->attributesFromType(
973            $this->getServerType( $i ),
974            $this->serverInfo->getServerDriver( $i )
975        );
976    }
977
978    /**
979     * Open a new network connection to a server (uncached)
980     *
981     * Returns a Database object whether or not the connection was successful.
982     *
983     * @param int $i Specific server index
984     * @param DatabaseDomain $domain Domain the connection is for, possibly unspecified
985     * @param array $lbInfo Additional information for setLBInfo()
986     * @return Database
987     * @throws DBAccessError
988     * @throws InvalidArgumentException
989     */
990    protected function reallyOpenConnection( $i, DatabaseDomain $domain, array $lbInfo ) {
991        if ( $this->disabled ) {
992            throw new DBAccessError();
993        }
994
995        $server = $this->serverInfo->getServerInfoStrict( $i );
996        if ( $lbInfo[self::INFO_CONN_CATEGORY] === self::CATEGORY_GAUGE ) {
997            // Use low connection/read timeouts for connection used for gauging server health.
998            // Gauge information should be cached and used to avoid outages. Indefinite hanging
999            // while gauging servers would do the opposite.
1000            $server['connectTimeout'] = min( 1, $server['connectTimeout'] ?? INF );
1001            $server['receiveTimeout'] = min( 1, $server['receiveTimeout'] ?? INF );
1002            // Avoid implicit transactions and avoid any SET query for session variables during
1003            // Database::open(). If a server becomes slow, every extra query can cause significant
1004            // delays, even with low connect/receive timeouts.
1005            $server['flags'] ??= 0;
1006            $server['flags'] &= ~IDatabase::DBO_DEFAULT;
1007            $server['flags'] |= IDatabase::DBO_GAUGE;
1008        } else {
1009            // Use implicit transactions unless explicitly configured otherwise
1010            $server['flags'] ??= IDatabase::DBO_DEFAULT;
1011        }
1012
1013        if ( !empty( $server['is static'] ) ) {
1014            $topologyRole = IDatabase::ROLE_STATIC_CLONE;
1015        } else {
1016            $topologyRole = ( $i === ServerInfo::WRITER_INDEX )
1017                ? IDatabase::ROLE_STREAMING_MASTER
1018                : IDatabase::ROLE_STREAMING_REPLICA;
1019        }
1020
1021        $conn = $this->databaseFactory->create(
1022            $server['type'],
1023            array_merge( $server, [
1024                // Basic replication role information
1025                'topologyRole' => $topologyRole,
1026                // Use the database specified in $domain (null means "none or entrypoint DB");
1027                // fallback to the $server default if the RDBMs is an embedded library using a
1028                // file on disk since there would be nothing to access to without a DB/file name.
1029                'dbname' => $this->getServerAttributes( $i )[Database::ATTR_DB_IS_FILE]
1030                    ? ( $domain->getDatabase() ?? $server['dbname'] ?? null )
1031                    : $domain->getDatabase(),
1032                // Override the $server default schema with that of $domain if specified
1033                'schema' => $domain->getSchema(),
1034                // Use the table prefix specified in $domain
1035                'tablePrefix' => $domain->getTablePrefix(),
1036                'srvCache' => $this->srvCache,
1037                'logger' => $this->logger,
1038                'errorLogger' => $this->errorLogger,
1039                'trxProfiler' => $this->trxProfiler,
1040                'lbInfo' => [ self::INFO_SERVER_INDEX => $i ] + $lbInfo
1041            ] ),
1042            Database::NEW_UNCONNECTED
1043        );
1044        // Set alternative table names before any queries can be issued
1045        $conn->setTableAliases( $this->tableAliases );
1046        // Account for any active transaction round and listeners
1047        $this->syncConnectionRoundState( $conn );
1048        if ( $i === ServerInfo::WRITER_INDEX ) {
1049            foreach ( $this->trxRecurringCallbacks as $name => $callback ) {
1050                $conn->setTransactionListener( $name, $callback );
1051            }
1052        }
1053
1054        // Make the connection handle live
1055        try {
1056            $conn->initConnection();
1057            ++$this->connectionCounter;
1058        } catch ( DBConnectionError ) {
1059            $this->lastErrorConn = $conn;
1060            // ignore; let the DB handle the logging
1061        }
1062
1063        if ( $conn->isOpen() ) {
1064            $this->logger->debug( __METHOD__ . ": opened new connection for $i/$domain" );
1065        } else {
1066            $this->logger->warning(
1067                __METHOD__ . ": connection error for $i/{db_domain}",
1068                [ 'db_domain' => $domain->getId() ]
1069            );
1070        }
1071
1072        // Log when many connection are made during a single request/script
1073        $count = 0;
1074        foreach ( $this->conns as $poolConnsByServer ) {
1075            foreach ( $poolConnsByServer as $serverConns ) {
1076                $count += count( $serverConns );
1077            }
1078        }
1079        if ( $count >= self::CONN_HELD_WARN_THRESHOLD ) {
1080            $this->logger->warning(
1081                __METHOD__ . ": {connections}+ connections made (primary={primarydb})",
1082                $this->getConnLogContext(
1083                    $conn,
1084                    [
1085                        'connections' => $count,
1086                        'primarydb' => $this->serverInfo->getPrimaryServerName(),
1087                        'db_domain' => $domain->getId()
1088                    ]
1089                )
1090            );
1091        }
1092
1093        $this->assertConnectionDomain( $conn, $domain );
1094
1095        return $conn;
1096    }
1097
1098    /**
1099     * Make sure that any "waitForPos" replication positions are loaded and available
1100     *
1101     * Each load balancer cluster has up to one replication position for the session.
1102     * These are used when data read by queries is expected to reflect writes caused
1103     * by a prior request/script from the same client.
1104     *
1105     * @see awaitSessionPrimaryPos()
1106     */
1107    private function loadSessionPrimaryPos() {
1108        if ( !$this->chronologyProtectorCalled && $this->chronologyProtector ) {
1109            $this->chronologyProtectorCalled = true;
1110            $pos = $this->chronologyProtector->getSessionPrimaryPos( $this );
1111            $this->logger->debug( __METHOD__ . ': executed chronology callback.' );
1112            if ( $pos ) {
1113                if ( !$this->waitForPos || $pos->hasReached( $this->waitForPos ) ) {
1114                    $this->waitForPos = $pos;
1115                }
1116            }
1117        }
1118    }
1119
1120    /**
1121     * @param string $extraLbError Separate load balancer error
1122     * @throws DBConnectionError
1123     * @return never
1124     */
1125    private function reportConnectionError( $extraLbError = '' ): never {
1126        if ( $this->lastErrorConn instanceof IDatabaseForOwner ) {
1127            $srvName = $this->lastErrorConn->getServerName();
1128            $lastDbError = $this->lastErrorConn->lastError() ?: 'unknown error';
1129
1130            $exception = new DBConnectionError(
1131                $this->lastErrorConn,
1132                $extraLbError
1133                    ? "{$extraLbError}; {$lastDbError} ({$srvName})"
1134                    : "{$lastDbError} ({$srvName})"
1135            );
1136
1137            if ( $extraLbError ) {
1138                $this->logger->warning(
1139                    __METHOD__ . ": $extraLbError; {last_error} ({db_server})",
1140                    $this->getConnLogContext(
1141                        $this->lastErrorConn,
1142                        [
1143                            'method' => __METHOD__,
1144                            'last_error' => $lastDbError
1145                        ]
1146                    )
1147                );
1148            }
1149        } else {
1150            $exception = new DBConnectionError(
1151                null,
1152                $extraLbError ?: 'could not connect to the DB server'
1153            );
1154
1155            if ( $extraLbError ) {
1156                $this->logger->error(
1157                    __METHOD__ . ": $extraLbError",
1158                    [
1159                        'method' => __METHOD__,
1160                        'last_error' => '(last connection error missing)'
1161                    ]
1162                );
1163            }
1164        }
1165
1166        throw $exception;
1167    }
1168
1169    /** @inheritDoc */
1170    public function getServerCount() {
1171        return $this->serverInfo->getServerCount();
1172    }
1173
1174    /** @inheritDoc */
1175    public function hasReplicaServers() {
1176        return $this->serverInfo->hasReplicaServers();
1177    }
1178
1179    /** @inheritDoc */
1180    public function hasStreamingReplicaServers() {
1181        return $this->serverInfo->hasStreamingReplicaServers();
1182    }
1183
1184    /** @inheritDoc */
1185    public function getServerName( $i ): string {
1186        return $this->serverInfo->getServerName( $i );
1187    }
1188
1189    /** @inheritDoc */
1190    public function getServerInfo( $i ) {
1191        return $this->serverInfo->getServerInfo( $i );
1192    }
1193
1194    /** @inheritDoc */
1195    public function getServerType( $i ) {
1196        return $this->serverInfo->getServerType( $i );
1197    }
1198
1199    /** @inheritDoc */
1200    public function getPrimaryPos() {
1201        $conn = $this->getAnyOpenConnection( ServerInfo::WRITER_INDEX );
1202        if ( $conn ) {
1203            return $conn->getPrimaryPos();
1204        }
1205
1206        /** @var IDatabaseForOwner|null $conn */
1207        $conn = $this->getConnectionInternal( ServerInfo::WRITER_INDEX, [], false, self::CONN_SILENCE_ERRORS );
1208        // @phan-suppress-next-line PhanRedundantCondition
1209        if ( !$conn ) {
1210            $this->reportConnectionError();
1211        }
1212
1213        // ::getConnectionInternal() should return IDatabaseForOwner but changing signature
1214        // is not straightforward (being implemented in Wikibase)
1215        '@phan-var IDatabaseForOwner $conn';
1216        try {
1217            return $conn->getPrimaryPos();
1218        } finally {
1219            $this->closeConnection( $conn );
1220        }
1221    }
1222
1223    /**
1224     * Apply updated configuration.
1225     *
1226     * This only unregisters servers that were removed in the new configuration.
1227     * It does not register new servers nor update the group load weights.
1228     *
1229     * This invalidates any open connections. However, existing connections may continue to be
1230     * used while they are in an active transaction. In that case, the old connection will be
1231     * discarded on the first operation after the transaction is complete. The next operation
1232     * will use a new connection based on the new configuration.
1233     *
1234     * @internal for use by LBFactory::reconfigure()
1235     *
1236     * @see DBConnRef::ensureConnection()
1237     * @see LBFactory::reconfigure()
1238     *
1239     * @param array $params A database configuration array, see $wgLBFactoryConf.
1240     *
1241     * @return void
1242     */
1243    public function reconfigure( array $params ) {
1244        $anyServerDepooled = false;
1245
1246        $paramServers = $params['servers'];
1247        $newIndexByServerIndex = $this->serverInfo->reconfigureServers( $paramServers );
1248        foreach ( $newIndexByServerIndex as $i => $ni ) {
1249            if ( $ni === null ) {
1250                // Server no longer exists in the new config
1251                $anyServerDepooled = true;
1252                // Note that if the primary server is depooled and a replica server promoted
1253                // to new primary, then DB_PRIMARY handles will fail with server index errors
1254                unset( $this->loads[$i] );
1255            }
1256        }
1257
1258        if ( $anyServerDepooled ) {
1259            // NOTE: We could close all connection here, but some may be in the middle of
1260            //       a transaction. So instead, we leave it to DBConnRef to close the
1261            //       connection when it detects that the modcount has changed and no
1262            //       transaction is open.
1263            $this->logger->info( 'Reconfiguring dbs!' );
1264            // Unpin DB_REPLICA connection groups from server indexes
1265            $this->readIndexByGroup = [];
1266            // We could close all connection here, but some may be in the middle of a
1267            // transaction. So instead, we leave it to DBConnRef to close the connection
1268            // when it detects that the modcount has changed and no transaction is open.
1269            $this->conns = self::newTrackedConnectionsArray();
1270            // Bump modification counter to invalidate the connections held by DBConnRef
1271            // instances. This will cause the next call to a method on the DBConnRef
1272            // to get a new connection from getConnectionInternal()
1273            $this->modcount++;
1274        }
1275    }
1276
1277    /** @inheritDoc */
1278    public function disable( $fname = __METHOD__ ) {
1279        $this->closeAll( $fname );
1280        $this->disabled = true;
1281    }
1282
1283    /** @inheritDoc */
1284    public function closeAll( $fname = __METHOD__ ) {
1285        /** @noinspection PhpUnusedLocalVariableInspection */
1286        $scope = ScopedCallback::newScopedIgnoreUserAbort();
1287        foreach ( $this->getOpenConnections() as $conn ) {
1288            $conn->close( $fname );
1289        }
1290
1291        $this->conns = self::newTrackedConnectionsArray();
1292    }
1293
1294    /**
1295     * Close a connection
1296     *
1297     * Using this function makes sure the LoadBalancer knows the connection is closed.
1298     * If you use $conn->close() directly, the load balancer won't update its state.
1299     */
1300    private function closeConnection( IDatabaseForOwner $conn ) {
1301        if ( $conn instanceof DBConnRef ) {
1302            // Avoid calling close() but still leaving the handle in the pool
1303            throw new RuntimeException( 'Cannot close DBConnRef instance; it must be shareable' );
1304        }
1305
1306        $domain = $conn->getDomainID();
1307        $serverIndex = $conn->getLBInfo( self::INFO_SERVER_INDEX );
1308        if ( $serverIndex === null ) {
1309            throw new UnexpectedValueException( "Handle on '$domain' missing server index" );
1310        }
1311
1312        $srvName = $this->serverInfo->getServerName( $serverIndex );
1313
1314        $found = false;
1315        foreach ( $this->conns as $type => $poolConnsByServer ) {
1316            $key = array_search( $conn, $poolConnsByServer[$serverIndex] ?? [], true );
1317            if ( $key !== false ) {
1318                $found = true;
1319                unset( $this->conns[$type][$serverIndex][$key] );
1320            }
1321        }
1322
1323        if ( !$found ) {
1324            $this->logger->warning(
1325                __METHOD__ .
1326                ": orphaned connection to database {$this->stringifyConn( $conn )} at '$srvName'."
1327            );
1328        }
1329
1330        $this->logger->debug(
1331            __METHOD__ .
1332            ": closing connection to database {$this->stringifyConn( $conn )} at '$srvName'."
1333        );
1334
1335        $conn->close( __METHOD__ );
1336    }
1337
1338    /** @inheritDoc */
1339    public function finalizePrimaryChanges( $fname = __METHOD__ ) {
1340        $this->assertTransactionRoundStage( [ self::ROUND_CURSORY, self::ROUND_FINALIZED ] );
1341        /** @noinspection PhpUnusedLocalVariableInspection */
1342        $scope = ScopedCallback::newScopedIgnoreUserAbort();
1343
1344        $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
1345        // Loop until callbacks stop adding callbacks on other connections
1346        $total = 0;
1347        do {
1348            $count = 0; // callbacks execution attempts
1349            foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1350                // Run any pre-commit callbacks while leaving the post-commit ones suppressed.
1351                // Any error should cause all (peer) transactions to be rolled back together.
1352                $count += $conn->runOnTransactionPreCommitCallbacks();
1353            }
1354            $total += $count;
1355        } while ( $count > 0 );
1356        // Defer post-commit callbacks until after COMMIT/ROLLBACK happens on all handles
1357        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1358            $conn->setTrxEndCallbackSuppression( true );
1359        }
1360        $this->trxRoundStage = self::ROUND_FINALIZED;
1361
1362        return $total;
1363    }
1364
1365    /** @inheritDoc */
1366    public function approvePrimaryChanges( int $maxWriteDuration, $fname = __METHOD__ ) {
1367        $this->assertTransactionRoundStage( self::ROUND_FINALIZED );
1368        /** @noinspection PhpUnusedLocalVariableInspection */
1369        $scope = ScopedCallback::newScopedIgnoreUserAbort();
1370
1371        $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
1372        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1373            // Any atomic sections should have been closed by now and there definitely should
1374            // not be any open transactions started by begin() from callers outside Database.
1375            if ( $conn->explicitTrxActive() ) {
1376                throw new DBTransactionError(
1377                    $conn,
1378                    "Explicit transaction still active; a caller might have failed to call " .
1379                    "endAtomic() or cancelAtomic()."
1380                );
1381            }
1382            // Assert that the time to replicate the transaction will be reasonable.
1383            // If this fails, then all DB transactions will be rollback back together.
1384            $time = $conn->pendingWriteQueryDuration( $conn::ESTIMATE_DB_APPLY );
1385            if ( $maxWriteDuration > 0 ) {
1386                if ( $time > $maxWriteDuration ) {
1387                    $humanTimeSec = round( $time, 3 );
1388                    throw new DBTransactionSizeError(
1389                        $conn,
1390                        "Transaction spent {time}s in writes, exceeding the {$maxWriteDuration}s limit",
1391                        // Message parameters for: transaction-duration-limit-exceeded
1392                        [ $time, $maxWriteDuration ],
1393                        null,
1394                        [ 'time' => $humanTimeSec ]
1395                    );
1396                } elseif ( $time > 0 ) {
1397                    $timeMs = $time * 1000;
1398                    $humanTimeMs = round( $timeMs, $timeMs > 1 ? 0 : 3 );
1399                    $this->logger->debug(
1400                        "Transaction spent {time_ms}ms in writes, under the {$maxWriteDuration}s limit",
1401                        [ 'time_ms' => $humanTimeMs ]
1402                    );
1403                }
1404            }
1405            // If a connection sits idle for too long it might be dropped, causing transaction
1406            // writes and session locks to be lost. Ping all the server connections before making
1407            // any attempt to commit the transactions belonging to the active transaction round.
1408            if ( $conn->writesOrCallbacksPending() || $conn->sessionLocksPending() ) {
1409                if ( !$conn->ping() ) {
1410                    throw new DBTransactionError(
1411                        $conn,
1412                        "Pre-commit ping failed on server {$conn->getServerName()}"
1413                    );
1414                }
1415            }
1416        }
1417        $this->trxRoundStage = self::ROUND_APPROVED;
1418    }
1419
1420    /** @inheritDoc */
1421    public function beginPrimaryChanges( $fname = __METHOD__ ) {
1422        if ( $this->trxRoundFname !== null ) {
1423            throw new DBTransactionError(
1424                null,
1425                "Transaction round '{$this->trxRoundFname}' already started"
1426            );
1427        }
1428        $this->assertTransactionRoundStage( self::ROUND_CURSORY );
1429        /** @noinspection PhpUnusedLocalVariableInspection */
1430        $scope = ScopedCallback::newScopedIgnoreUserAbort();
1431
1432        // Clear any empty transactions (no writes/callbacks) from the implicit round
1433        $this->flushPrimarySnapshots( $fname );
1434
1435        $this->trxRoundFname = $fname;
1436        $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
1437        // Mark applicable handles as participating in this explicit transaction round.
1438        // For each of these handles, any writes and callbacks will be tied to a single
1439        // transaction. The (peer) handles will reject begin()/commit() calls unless they
1440        // are part of an en masse commit or an en masse rollback.
1441        foreach ( $this->getOpenConnections() as $conn ) {
1442            $this->syncConnectionRoundState( $conn );
1443        }
1444        $this->trxRoundStage = self::ROUND_CURSORY;
1445    }
1446
1447    /** @inheritDoc */
1448    public function commitPrimaryChanges( $fname = __METHOD__ ) {
1449        $this->assertTransactionRoundStage( self::ROUND_APPROVED );
1450        /** @noinspection PhpUnusedLocalVariableInspection */
1451        $scope = ScopedCallback::newScopedIgnoreUserAbort();
1452
1453        $failures = [];
1454
1455        $this->trxRoundFname = null;
1456        $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
1457        // Commit any writes and clear any snapshots as well (callbacks require AUTOCOMMIT).
1458        // Note that callbacks should already be suppressed due to finalizePrimaryChanges().
1459        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1460            try {
1461                $conn->commit( $fname, $conn::FLUSHING_ALL_PEERS );
1462            } catch ( DBError $e ) {
1463                ( $this->errorLogger )( $e );
1464                $failures[] = "{$conn->getServerName()}: {$e->getMessage()}";
1465            }
1466        }
1467        if ( $failures ) {
1468            throw new DBTransactionError(
1469                null,
1470                "Commit failed on server(s) " . implode( "\n", array_unique( $failures ) )
1471            );
1472        }
1473        // Unmark handles as participating in this explicit transaction round
1474        foreach ( $this->getOpenConnections() as $conn ) {
1475            $this->syncConnectionRoundState( $conn );
1476        }
1477        $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS;
1478    }
1479
1480    /** @inheritDoc */
1481    public function runPrimaryTransactionIdleCallbacks( $fname = __METHOD__ ) {
1482        if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) {
1483            $type = IDatabase::TRIGGER_COMMIT;
1484        } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) {
1485            $type = IDatabase::TRIGGER_ROLLBACK;
1486        } else {
1487            throw new DBTransactionError(
1488                null,
1489                "Transaction should be in the callback stage (not '{$this->trxRoundStage}')"
1490            );
1491        }
1492        /** @noinspection PhpUnusedLocalVariableInspection */
1493        $scope = ScopedCallback::newScopedIgnoreUserAbort();
1494
1495        $oldStage = $this->trxRoundStage;
1496        $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
1497
1498        // Now that the COMMIT/ROLLBACK step is over, enable post-commit callback runs
1499        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1500            $conn->setTrxEndCallbackSuppression( false );
1501        }
1502
1503        $errors = [];
1504        $fname = __METHOD__;
1505        // Loop until callbacks stop adding callbacks on other connections
1506        do {
1507            // Run any pending callbacks for each connection...
1508            $count = 0; // callback execution attempts
1509            foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1510                if ( $conn->trxLevel() ) {
1511                    continue; // retry in the next iteration, after commit() is called
1512                }
1513                $count += $conn->runOnTransactionIdleCallbacks( $type, $errors );
1514            }
1515            // Clear out any active transactions left over from callbacks...
1516            foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1517                if ( $conn->writesPending() ) {
1518                    // A callback from another handle wrote to this one and DBO_TRX is set
1519                    $fnames = implode( ', ', $conn->pendingWriteCallers() );
1520                    $this->logger->info(
1521                        "$fname: found writes pending ($fnames).",
1522                        $this->getConnLogContext(
1523                            $conn,
1524                            [ 'exception' => new RuntimeException() ]
1525                        )
1526                    );
1527                    $this->statsFactory->getCounter( 'rdbms_callback_writes_found_total' )
1528                        ->setLabel( 'db_cluster', $this->getClusterName() )
1529                        ->setLabel( 'db_server', $conn->getServerName() )
1530                        ->increment();
1531                } elseif ( $conn->trxLevel() ) {
1532                    // A callback from another handle read from this one and DBO_TRX is set,
1533                    // which can easily happen if there is only one DB (no replicas)
1534                    $this->logger->debug( "$fname: found empty transaction." );
1535                }
1536                try {
1537                    $conn->commit( $fname, $conn::FLUSHING_ALL_PEERS );
1538                } catch ( DBError $ex ) {
1539                    $errors[] = $ex;
1540                }
1541            }
1542        } while ( $count > 0 );
1543
1544        $this->trxRoundStage = $oldStage;
1545
1546        return $errors[0] ?? null;
1547    }
1548
1549    /** @inheritDoc */
1550    public function runPrimaryTransactionListenerCallbacks( $fname = __METHOD__ ) {
1551        if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) {
1552            $type = IDatabase::TRIGGER_COMMIT;
1553        } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) {
1554            $type = IDatabase::TRIGGER_ROLLBACK;
1555        } else {
1556            throw new DBTransactionError(
1557                null,
1558                "Transaction should be in the callback stage (not '{$this->trxRoundStage}')"
1559            );
1560        }
1561        /** @noinspection PhpUnusedLocalVariableInspection */
1562        $scope = ScopedCallback::newScopedIgnoreUserAbort();
1563
1564        $errors = [];
1565        $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
1566        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1567            $conn->runTransactionListenerCallbacks( $type, $errors );
1568        }
1569        $this->trxRoundStage = self::ROUND_CURSORY;
1570
1571        return $errors[0] ?? null;
1572    }
1573
1574    /** @inheritDoc */
1575    public function rollbackPrimaryChanges( $fname = __METHOD__ ) {
1576        /** @noinspection PhpUnusedLocalVariableInspection */
1577        $scope = ScopedCallback::newScopedIgnoreUserAbort();
1578
1579        $this->trxRoundFname = null;
1580        $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
1581        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1582            $conn->rollback( $fname, $conn::FLUSHING_ALL_PEERS );
1583        }
1584        // Unmark handles as participating in this explicit transaction round
1585        foreach ( $this->getOpenConnections() as $conn ) {
1586            $this->syncConnectionRoundState( $conn );
1587        }
1588        $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS;
1589    }
1590
1591    /** @inheritDoc */
1592    public function flushPrimarySessions( $fname = __METHOD__ ) {
1593        $this->assertTransactionRoundStage( [ self::ROUND_CURSORY ] );
1594        if ( $this->hasPrimaryChanges() ) {
1595            // Any transaction should have been rolled back beforehand
1596            throw new DBTransactionError( null, "Cannot reset session while writes are pending" );
1597        }
1598
1599        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1600            $conn->flushSession( $fname, $conn::FLUSHING_ALL_PEERS );
1601        }
1602    }
1603
1604    /**
1605     * @param string|string[] $stage
1606     * @throws DBTransactionError
1607     */
1608    private function assertTransactionRoundStage( $stage ) {
1609        $stages = (array)$stage;
1610
1611        if ( !in_array( $this->trxRoundStage, $stages, true ) ) {
1612            $stageList = implode(
1613                '/',
1614                array_map( static fn ( $v ) => "'$v'", $stages )
1615            );
1616            $transactionName = $this->trxRoundFname ?? '<unknown>';
1617            throw new DBTransactionError(
1618                null,
1619                "Transaction '$transactionName' round stage must be '$stageList' (not '{$this->trxRoundStage}')"
1620            );
1621        }
1622    }
1623
1624    /**
1625     * Make connections with DBO_DEFAULT/DBO_TRX set join any active transaction round
1626     *
1627     * Some servers may have neither flag enabled, meaning that they opt out of such
1628     * transaction rounds and remain in auto-commit mode. Such behavior might be desired
1629     * when a DB server is used for something like simple key/value storage.
1630     *
1631     * @param Database $conn
1632     */
1633    private function syncConnectionRoundState( Database $conn ) {
1634        if ( $conn->getLBInfo( self::INFO_CONN_CATEGORY ) !== self::CATEGORY_ROUND ) {
1635            return; // transaction rounds do not apply to these connections
1636        }
1637
1638        if ( $this->trxRoundFname !== null ) {
1639            // Explicit transaction round
1640            $trxRoundLevel = 1;
1641        } else {
1642            // Implicit auto-commit round (cli mode) or implicit transaction round (web mode)
1643            $trxRoundLevel = ( $this->cliMode ? 0 : 1 );
1644        }
1645
1646        // CATEGORY_ROUND DB handles with DBO_DEFAULT are considered "round aware" and the
1647        // load balancer will take over the logic of when DBO_TRX is set for such DB handles.
1648        // DBO_TRX will be set only when a transaction round is active (explicit or implicit).
1649        if ( $conn->getFlag( $conn::DBO_DEFAULT ) ) {
1650            if ( $trxRoundLevel ) {
1651                // Wrap queries in a transaction joined to the active transaction round
1652                $conn->setFlag( $conn::DBO_TRX );
1653            } else {
1654                // Do not wrap queries in a transaction (no active transaction round)
1655                $conn->clearFlag( $conn::DBO_TRX );
1656            }
1657        }
1658
1659        // Note that DBO_TRX is normally only set above or by DatabaseFactory applying
1660        // DBO_DEFAULT. However, integration tests might directly force DBO_TRX in the
1661        // server configuration arrays. Such handles will still be flushed during calls
1662        // to {@link LoadBalancer::commitPrimaryChanges()}.
1663        if ( $conn->getFlag( $conn::DBO_TRX ) ) {
1664            // DB handle is participating in the active transaction round
1665            $conn->setLBInfo( $conn::LB_TRX_ROUND_LEVEL, $trxRoundLevel );
1666            $conn->setLBInfo( $conn::LB_TRX_ROUND_FNAME, $this->trxRoundFname );
1667        } else {
1668            // DB handle is not participating in any transaction rounds
1669            $conn->setLBInfo( $conn::LB_TRX_ROUND_LEVEL, 0 );
1670            $conn->setLBInfo( $conn::LB_TRX_ROUND_FNAME, null );
1671        }
1672    }
1673
1674    /** @inheritDoc */
1675    public function flushReplicaSnapshots( $fname = __METHOD__ ) {
1676        foreach ( $this->conns as $poolConnsByServer ) {
1677            foreach ( $poolConnsByServer as $serverIndex => $serverConns ) {
1678                if ( $serverIndex === ServerInfo::WRITER_INDEX ) {
1679                    continue; // skip primary
1680                }
1681                foreach ( $serverConns as $conn ) {
1682                    $conn->flushSnapshot( $fname );
1683                }
1684            }
1685        }
1686    }
1687
1688    /** @inheritDoc */
1689    public function flushPrimarySnapshots( $fname = __METHOD__ ) {
1690        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1691            $conn->flushSnapshot( $fname );
1692        }
1693    }
1694
1695    /** @inheritDoc */
1696    public function hasPrimaryConnection() {
1697        return (bool)$this->getAnyOpenConnection( ServerInfo::WRITER_INDEX );
1698    }
1699
1700    /** @inheritDoc */
1701    public function hasPrimaryChanges() {
1702        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1703            if ( $conn->writesOrCallbacksPending() ) {
1704                return true;
1705            }
1706        }
1707
1708        return false;
1709    }
1710
1711    /** @inheritDoc */
1712    public function lastPrimaryChangeTimestamp() {
1713        $lastTime = null;
1714        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1715            $lastTime = max( $lastTime, $conn->lastDoneWrites() );
1716        }
1717
1718        return $lastTime;
1719    }
1720
1721    /** @inheritDoc */
1722    public function hasOrMadeRecentPrimaryChanges( $age = null ) {
1723        $age ??= self::MAX_WAIT_DEFAULT;
1724
1725        return ( $this->hasPrimaryChanges()
1726            || $this->lastPrimaryChangeTimestamp() > microtime( true ) - $age );
1727    }
1728
1729    /** @inheritDoc */
1730    public function pendingPrimaryChangeCallers() {
1731        $fnames = [];
1732        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1733            $fnames = array_merge( $fnames, $conn->pendingWriteCallers() );
1734        }
1735
1736        return $fnames;
1737    }
1738
1739    /** @inheritDoc */
1740    public function explicitTrxActive() {
1741        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1742            if ( $conn->explicitTrxActive() ) {
1743                return true;
1744            }
1745        }
1746        return false;
1747    }
1748
1749    private function setLaggedReplicaMode(): void {
1750        $this->laggedReplicaMode = true;
1751        $this->logger->warning( __METHOD__ . ": setting lagged replica mode" );
1752    }
1753
1754    /** @inheritDoc */
1755    public function laggedReplicaUsed() {
1756        return $this->laggedReplicaMode;
1757    }
1758
1759    /** @inheritDoc */
1760    public function getReadOnlyReason() {
1761        if ( $this->readOnlyReason !== false ) {
1762            return $this->readOnlyReason;
1763        } elseif ( $this->isPrimaryRunningReadOnly() ) {
1764            return 'The primary database server is running in read-only mode.';
1765        }
1766
1767        return false;
1768    }
1769
1770    /**
1771     * @note This method suppresses DBError exceptions in order to avoid severe downtime
1772     * @param IDatabaseForOwner|null $conn Recently acquired primary connection; null if not applicable
1773     * @return bool Whether the entire primary DB server or the local domain DB is read-only
1774     */
1775    private function isPrimaryRunningReadOnly( ?IDatabaseForOwner $conn = null ) {
1776        // Context will often be HTTP GET/HEAD; heavily cache the results
1777        return (bool)$this->wanCache->getWithSetCallback(
1778            // Note that table prefixes are not related to server-side read-only mode
1779            $this->wanCache->makeGlobalKey(
1780                'rdbms-server-readonly',
1781                $this->serverInfo->getPrimaryServerName()
1782            ),
1783            self::TTL_CACHE_READONLY,
1784            function ( $oldValue ) use ( $conn ) {
1785                $scope = $this->trxProfiler->silenceForScope();
1786                $conn ??= $this->getServerConnection(
1787                    ServerInfo::WRITER_INDEX,
1788                    self::DOMAIN_ANY,
1789                    self::CONN_SILENCE_ERRORS
1790                );
1791                if ( $conn ) {
1792                    try {
1793                        $value = (int)$conn->serverIsReadOnly();
1794                    } catch ( DBError ) {
1795                        $value = is_int( $oldValue ) ? $oldValue : 0;
1796                    }
1797                } else {
1798                    $value = 0;
1799                }
1800                ScopedCallback::consume( $scope );
1801
1802                return $value;
1803            },
1804            [
1805                'busyValue' => 0,
1806                'pcTTL' => WANObjectCache::TTL_PROC_LONG
1807            ]
1808        );
1809    }
1810
1811    /** @inheritDoc */
1812    public function pingAll() {
1813        $success = true;
1814        foreach ( $this->getOpenConnections() as $conn ) {
1815            if ( !$conn->ping() ) {
1816                $success = false;
1817            }
1818        }
1819
1820        return $success;
1821    }
1822
1823    /**
1824     * Get all open connections
1825     * @return \Generator|Database[]
1826     */
1827    private function getOpenConnections() {
1828        foreach ( $this->conns as $poolConnsByServer ) {
1829            foreach ( $poolConnsByServer as $serverConns ) {
1830                foreach ( $serverConns as $conn ) {
1831                    yield $conn;
1832                }
1833            }
1834        }
1835    }
1836
1837    /**
1838     * Get all open primary connections
1839     * @return \Generator|Database[]
1840     */
1841    private function getOpenPrimaryConnections() {
1842        foreach ( $this->conns as $poolConnsByServer ) {
1843            /** @var IDatabaseForOwner $conn */
1844            foreach ( ( $poolConnsByServer[ServerInfo::WRITER_INDEX] ?? [] ) as $conn ) {
1845                yield $conn;
1846            }
1847        }
1848    }
1849
1850    /** @inheritDoc */
1851    public function getMaxLag() {
1852        $host = '';
1853        $maxLag = -1;
1854        $maxIndex = 0;
1855
1856        if ( $this->serverInfo->hasReplicaServers() ) {
1857            $lagTimes = $this->getLagTimes();
1858            foreach ( $lagTimes as $i => $lag ) {
1859                // Allowing the value to be unset due to stale cache (T361824)
1860                $load = $this->loads[$i] ?? 0;
1861                if ( $load > 0 && $lag > $maxLag ) {
1862                    $maxLag = $lag;
1863                    $host = $this->serverInfo->getServerInfoStrict( $i, 'host' );
1864                    $maxIndex = $i;
1865                }
1866            }
1867        }
1868
1869        return [ $host, $maxLag, $maxIndex ];
1870    }
1871
1872    /** @inheritDoc */
1873    public function getLagTimes() {
1874        if ( !$this->hasReplicaServers() ) {
1875            return [ ServerInfo::WRITER_INDEX => 0 ]; // no replication = no lag
1876        }
1877        $fname = __METHOD__;
1878        return $this->wanCache->getWithSetCallback(
1879            $this->wanCache->makeGlobalKey( 'rdbms-lags', $this->getClusterName() ),
1880            // Add jitter to avoid stampede
1881            10 + mt_rand( 1, 10 ),
1882            function () use ( $fname ) {
1883                $lags = [];
1884                foreach ( $this->serverInfo->getStreamingReplicaIndexes() as $i ) {
1885                    $conn = $this->getServerConnection(
1886                        $i,
1887                        self::DOMAIN_ANY,
1888                        self::CONN_SILENCE_ERRORS | self::CONN_UNTRACKED_GAUGE
1889                    );
1890                    if ( $conn ) {
1891                        $lags[$i] = $conn->getLag();
1892                        $conn->close( $fname );
1893                    } else {
1894                        $lags[$i] = false;
1895                    }
1896                }
1897                return $lags;
1898            },
1899            [ 'lockTSE' => 30 ]
1900        );
1901    }
1902
1903    /** @inheritDoc */
1904    public function waitForPrimaryPos( IDatabase $conn ) {
1905        if ( $conn->getLBInfo( self::INFO_SERVER_INDEX ) === ServerInfo::WRITER_INDEX ) {
1906            return true; // not a replica DB server
1907        }
1908
1909        // Get the current primary DB position, opening a connection only if needed
1910        $flags = self::CONN_SILENCE_ERRORS;
1911        $primaryConn = $this->getAnyOpenConnection( ServerInfo::WRITER_INDEX, $flags );
1912        if ( $primaryConn ) {
1913            $pos = $primaryConn->getPrimaryPos();
1914        } else {
1915            $primaryConn = $this->getServerConnection( ServerInfo::WRITER_INDEX, self::DOMAIN_ANY, $flags );
1916            if ( !$primaryConn ) {
1917                throw new DBReplicationWaitError(
1918                    null,
1919                    "Could not obtain a primary database connection to get the position"
1920                );
1921            }
1922            $pos = $primaryConn->getPrimaryPos();
1923            $this->closeConnection( $primaryConn );
1924        }
1925
1926        if ( $pos instanceof DBPrimaryPos && $conn instanceof IDatabaseForOwner ) {
1927            $this->logger->debug( __METHOD__ . ': waiting' );
1928            $result = $conn->primaryPosWait( $pos, self::MAX_WAIT_DEFAULT );
1929            $ok = ( $result !== null && $result != -1 );
1930            if ( $ok ) {
1931                $this->logger->debug( __METHOD__ . ': done waiting (success)' );
1932            } else {
1933                $this->logger->debug( __METHOD__ . ': done waiting (failure)' );
1934            }
1935        } else {
1936            $ok = false; // something is misconfigured
1937            $this->logger->error(
1938                __METHOD__ . ': could not get primary pos for {db_server}',
1939                $this->getConnLogContext( $conn, [ 'exception' => new RuntimeException() ] )
1940            );
1941        }
1942
1943        return $ok;
1944    }
1945
1946    /** @inheritDoc */
1947    public function setTransactionListener( $name, ?callable $callback = null ) {
1948        if ( $callback ) {
1949            $this->trxRecurringCallbacks[$name] = $callback;
1950        } else {
1951            unset( $this->trxRecurringCallbacks[$name] );
1952        }
1953        foreach ( $this->getOpenPrimaryConnections() as $conn ) {
1954            $conn->setTransactionListener( $name, $callback );
1955        }
1956    }
1957
1958    public function setTableAliases( array $aliases ) {
1959        $this->tableAliases = $aliases;
1960    }
1961
1962    public function setDomainAliases( array $aliases ) {
1963        $this->domainAliases = $aliases;
1964    }
1965
1966    /** @inheritDoc */
1967    public function setLocalDomainPrefix( $prefix ) {
1968        $oldLocalDomain = $this->localDomain;
1969        $this->localDomain = new DatabaseDomain(
1970            $this->localDomain->getDatabase(),
1971            $this->localDomain->getSchema(),
1972            $prefix
1973        );
1974
1975        // Update the prefix for existing connections.
1976        // Existing DBConnRef handles will not be affected.
1977        foreach ( $this->getOpenConnections() as $conn ) {
1978            if ( $oldLocalDomain->equals( $conn->getDomainID() ) ) {
1979                $conn->tablePrefix( $prefix );
1980            }
1981        }
1982    }
1983
1984    /** @inheritDoc */
1985    public function redefineLocalDomain( $domain ) {
1986        $this->closeAll( __METHOD__ );
1987        $this->localDomain = DatabaseDomain::newFromId( $domain );
1988    }
1989
1990    /** @inheritDoc */
1991    public function setTempTablesOnlyMode( $value, $domain ) {
1992        $old = $this->tempTablesOnlyMode[$domain] ?? false;
1993        if ( $value ) {
1994            $this->tempTablesOnlyMode[$domain] = true;
1995        } else {
1996            unset( $this->tempTablesOnlyMode[$domain] );
1997        }
1998
1999        return $old;
2000    }
2001
2002    /**
2003     * @param IDatabase $conn
2004     * @return string Description of a connection handle for log messages
2005     * @throws InvalidArgumentException
2006     */
2007    private function stringifyConn( IDatabase $conn ) {
2008        return $conn->getLBInfo( self::INFO_SERVER_INDEX ) . '/' . $conn->getDomainID();
2009    }
2010
2011    /**
2012     * @param int $flags A bitfield of flags
2013     * @param int $bit Bit flag constant
2014     * @return bool Whether the bit field has the specified bit flag set
2015     */
2016    private function fieldHasBit( int $flags, int $bit ) {
2017        return ( ( $flags & $bit ) === $bit );
2018    }
2019
2020    /**
2021     * Create a log context to pass to PSR-3 logger functions.
2022     *
2023     * @param IDatabase $conn
2024     * @param array $extras Additional data to add to context
2025     * @return array
2026     */
2027    protected function getConnLogContext( IDatabase $conn, array $extras = [] ) {
2028        return $extras + [
2029            'db_server' => $conn->getServerName(),
2030            'db_domain' => $conn->getDomainID(),
2031        ];
2032    }
2033
2034    /**
2035     * @param float|null &$time Mock UNIX timestamp for testing
2036     * @internal
2037     * @codeCoverageIgnore
2038     */
2039    public function setMockTime( &$time ) {
2040        if ( !$this->loadMonitor instanceof LoadMonitor ) {
2041            throw new LogicException( 'Cannot set mock time on ' . $this::class . ' (consider adding ' .
2042                'setMockTime to ILoadMonitor).' );
2043        }
2044        $this->loadMonitor->setMockTime( $time );
2045    }
2046
2047    public function setDefaultGroupName( string $defaultGroup ): void {
2048        $this->defaultGroup = $defaultGroup;
2049    }
2050}