Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 273
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
MysqlReplicationReporter
0.00% covered (danger)
0.00%
0 / 273
0.00% covered (danger)
0.00%
0 / 15
3540
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 doGetLag
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getLagFromSlaveStatus
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getLagFromPtHeartbeat
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 fetchSecondsSinceHeartbeat
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getApproximateLagStatus
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getReplicationSafetyInfo
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 useGTIDs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 primaryPosWait
0.00% covered (danger)
0.00%
0 / 98
0.00% covered (danger)
0.00%
0 / 1
182
 getReplicaPos
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 getPrimaryPos
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
110
 getServerId
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 getServerUUID
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 getServerGTIDs
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 getServerRoleStatus
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\Rdbms\Replication;
7
8use InvalidArgumentException;
9use Psr\Log\LoggerInterface;
10use RuntimeException;
11use stdClass;
12use Wikimedia\ObjectCache\BagOStuff;
13use Wikimedia\Rdbms\DBPrimaryPos;
14use Wikimedia\Rdbms\DBQueryError;
15use Wikimedia\Rdbms\IDatabase;
16use Wikimedia\Rdbms\MySQLPrimaryPos;
17use Wikimedia\Rdbms\Platform\ISQLPlatform;
18use Wikimedia\Rdbms\Query;
19use Wikimedia\Timestamp\ConvertibleTimestamp;
20
21/**
22 * @internal
23 * @ingroup Database
24 * @since 1.40
25 */
26class MysqlReplicationReporter extends ReplicationReporter {
27    /** @var MySQLPrimaryPos */
28    protected $lastKnownReplicaPos;
29    /** @var string Method to detect replica DB lag */
30    protected $lagDetectionMethod;
31    /** @var array Method to detect replica DB lag */
32    protected $lagDetectionOptions = [];
33    /** @var bool bool Whether to use GTID methods */
34    protected $useGTIDs = false;
35    /** @var stdClass|null */
36    private $replicationInfoRow;
37    // Cache getServerId() for 24 hours
38    private const SERVER_ID_CACHE_TTL = 86400;
39
40    /** @var float Warn if lag estimates are made for transactions older than this many seconds */
41    private const LAG_STALE_WARN_THRESHOLD = 0.100;
42
43    /**
44     * @param string $topologyRole
45     * @param LoggerInterface $logger
46     * @param BagOStuff $srvCache
47     * @param string $lagDetectionMethod
48     * @param array $lagDetectionOptions
49     * @param bool $useGTIDs
50     */
51    public function __construct(
52        $topologyRole,
53        $logger,
54        $srvCache,
55        $lagDetectionMethod,
56        $lagDetectionOptions,
57        $useGTIDs
58    ) {
59        parent::__construct( $topologyRole, $logger, $srvCache );
60        $this->lagDetectionMethod = $lagDetectionMethod;
61        $this->lagDetectionOptions = $lagDetectionOptions;
62        $this->useGTIDs = $useGTIDs;
63    }
64
65    /** @inheritDoc */
66    protected function doGetLag( IDatabase $conn ) {
67        if ( $this->lagDetectionMethod === 'pt-heartbeat' ) {
68            return $this->getLagFromPtHeartbeat( $conn );
69        } else {
70            return $this->getLagFromSlaveStatus( $conn );
71        }
72    }
73
74    /**
75     * @param IDatabase $conn To make queries
76     * @return int|false Second of lag
77     */
78    protected function getLagFromSlaveStatus( IDatabase $conn ) {
79        $query = new Query(
80            'SHOW SLAVE STATUS',
81            ISQLPlatform::QUERY_SILENCE_ERRORS | ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE,
82            'SHOW',
83            null,
84            'SHOW SLAVE STATUS'
85        );
86        $res = $conn->query( $query, __METHOD__ );
87        $row = $res ? $res->fetchObject() : false;
88        // If the server is not replicating, there will be no row
89        if ( $row && strval( $row->Seconds_Behind_Master ) !== '' ) {
90            // https://mariadb.com/kb/en/delayed-replication/
91            // https://dev.mysql.com/doc/refman/5.6/en/replication-delayed.html
92            return intval( $row->Seconds_Behind_Master + ( $row->SQL_Remaining_Delay ?? 0 ) );
93        }
94
95        return false;
96    }
97
98    /**
99     * @param IDatabase $conn To make queries
100     * @return float|false Seconds of lag
101     */
102    protected function getLagFromPtHeartbeat( IDatabase $conn ) {
103        $currentTrxInfo = $this->getRecordedTransactionLagStatus( $conn );
104        if ( $currentTrxInfo ) {
105            // There is an active transaction and the initial lag was already queried
106            $staleness = microtime( true ) - $currentTrxInfo['since'];
107            if ( $staleness > self::LAG_STALE_WARN_THRESHOLD ) {
108                // Avoid returning higher and higher lag value due to snapshot age
109                // given that the isolation level will typically be REPEATABLE-READ
110                // but UTC_TIMESTAMP() is not affected by point-in-time snapshots
111                $this->logger->warning(
112                    "Using cached lag value for {db_server} due to active transaction",
113                    $this->getLogContext( $conn, [
114                        'method' => __METHOD__,
115                        'age' => $staleness,
116                        'exception' => new RuntimeException()
117                    ] )
118                );
119            }
120
121            return $currentTrxInfo['lag'];
122        }
123
124        $ago = $this->fetchSecondsSinceHeartbeat( $conn );
125        if ( $ago !== null ) {
126            return max( $ago, 0.0 );
127        }
128
129        $this->logger->error(
130            "Unable to find pt-heartbeat row for {db_server}",
131            $this->getLogContext( $conn, [
132                'method' => __METHOD__
133            ] )
134        );
135
136        return false;
137    }
138
139    /**
140     * @param IDatabase $conn To make queries
141     * @return float|null Elapsed seconds since the newest beat or null if none was found
142     * @see https://www.percona.com/doc/percona-toolkit/2.1/pt-heartbeat.html
143     */
144    protected function fetchSecondsSinceHeartbeat( IDatabase $conn ) {
145        // Some setups might have pt-heartbeat running on each replica server.
146        // Exclude the row for events originating on this DB server. Assume that
147        // there is only one active replication channel and that any other row
148        // getting updates must be the row for the primary DB server.
149        $where = $conn->makeList(
150            $this->lagDetectionOptions['conds'] ?? [ 'server_id != @@server_id' ],
151            ISQLPlatform::LIST_AND
152        );
153        // User mysql server time so that query time and trip time are not counted.
154        // Use ORDER BY for channel based queries since that field might not be UNIQUE.
155        $query = new Query(
156            "SELECT TIMESTAMPDIFF(MICROSECOND,ts,UTC_TIMESTAMP(6)) AS us_ago " .
157            "FROM heartbeat.heartbeat WHERE $where ORDER BY ts DESC LIMIT 1",
158            ISQLPlatform::QUERY_SILENCE_ERRORS | ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE,
159            'SELECT',
160            null,
161            "SELECT TIMESTAMPDIFF(MICROSECOND,ts,UTC_TIMESTAMP(6)) AS us_ago " .
162            "FROM heartbeat.heartbeat WHERE ? ORDER BY ts DESC LIMIT 1",
163        );
164        $res = $conn->query( $query, __METHOD__ );
165        $row = $res ? $res->fetchObject() : false;
166
167        return $row ? ( $row->us_ago / 1e6 ) : null;
168    }
169
170    /** @inheritDoc */
171    public function getApproximateLagStatus( IDatabase $conn ) {
172        if ( $this->lagDetectionMethod === 'pt-heartbeat' ) {
173            // Disable caching since this is fast enough and we don't want
174            // to be *too* pessimistic by having both the cache TTL and the
175            // pt-heartbeat interval count as lag in getSessionLagStatus()
176            return parent::getApproximateLagStatus( $conn );
177        }
178
179        $key = $this->srvCache->makeGlobalKey( 'mysql-lag', $conn->getServerName() );
180        $approxLag = $this->srvCache->get( $key );
181        if ( !$approxLag ) {
182            $approxLag = parent::getApproximateLagStatus( $conn );
183            $this->srvCache->set( $key, $approxLag, 1 );
184        }
185
186        return $approxLag;
187    }
188
189    /**
190     * @param IDatabase $conn To make queries
191     * @param string $fname
192     * @return stdClass Process cached row
193     */
194    public function getReplicationSafetyInfo( IDatabase $conn, $fname ) {
195        if ( $this->replicationInfoRow === null ) {
196            $this->replicationInfoRow = $conn->selectRow(
197                [],
198                [
199                    'innodb_autoinc_lock_mode' => '@@innodb_autoinc_lock_mode',
200                    'binlog_format' => '@@binlog_format',
201                ],
202                [],
203                $fname
204            );
205        }
206        return $this->replicationInfoRow;
207    }
208
209    /**
210     * @return bool Whether GTID support is used (mockable for testing)
211     */
212    protected function useGTIDs() {
213        return $this->useGTIDs;
214    }
215
216    /** @inheritDoc */
217    public function primaryPosWait( IDatabase $conn, DBPrimaryPos $pos, $timeout ) {
218        if ( !( $pos instanceof MySQLPrimaryPos ) ) {
219            throw new InvalidArgumentException( "Position not an instance of MySQLPrimaryPos" );
220        }
221
222        if ( $this->topologyRole === IDatabase::ROLE_STATIC_CLONE ) {
223            $this->logger->debug(
224                "Bypassed replication wait; database has a static dataset",
225                $this->getLogContext( $conn, [ 'method' => __METHOD__, 'raw_pos' => $pos ] )
226            );
227
228            return 0; // this is a copy of a read-only dataset with no primary DB
229        } elseif ( $this->lastKnownReplicaPos && $this->lastKnownReplicaPos->hasReached( $pos ) ) {
230            $this->logger->debug(
231                "Bypassed replication wait; replication known to have reached {raw_pos}",
232                $this->getLogContext( $conn, [ 'method' => __METHOD__, 'raw_pos' => $pos ] )
233            );
234
235            return 0; // already reached this point for sure
236        }
237
238        // Call doQuery() directly, to avoid opening a transaction if DBO_TRX is set
239        if ( $pos->getGTIDs() ) {
240            // Get the GTIDs from this replica server too see the domains (channels)
241            $refPos = $this->getReplicaPos( $conn );
242            if ( !$refPos ) {
243                $this->logger->error(
244                    "Could not get replication position on replica DB to compare to {raw_pos}",
245                    $this->getLogContext( $conn, [ 'method' => __METHOD__, 'raw_pos' => $pos ] )
246                );
247
248                return -1; // this is the primary DB itself?
249            }
250            // GTIDs with domains (channels) that are active and are present on the replica
251            $gtidsWait = $pos::getRelevantActiveGTIDs( $pos, $refPos );
252            if ( !$gtidsWait ) {
253                $this->logger->error(
254                    "No active GTIDs in {raw_pos} share a domain with those in {current_pos}",
255                    $this->getLogContext( $conn, [
256                        'method' => __METHOD__,
257                        'raw_pos' => $pos,
258                        'current_pos' => $refPos
259                    ] )
260                );
261
262                return -1; // $pos is from the wrong cluster?
263            }
264            // Wait on the GTID set
265            $gtidArg = $conn->addQuotes( implode( ',', $gtidsWait ) );
266            if ( str_contains( $gtidArg, ':' ) ) {
267                // MySQL GTIDs, e.g "source_id:transaction_id"
268                $query = new Query(
269                    "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)",
270                    ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE,
271                    'SELECT',
272                    null,
273                    "SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)"
274                );
275            } else {
276                // MariaDB GTIDs, e.g."domain:server:sequence"
277                $query = new Query(
278                    "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)",
279                    ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE,
280                    'SELECT',
281                    null,
282                    "SELECT MASTER_GTID_WAIT(?, ?)"
283                );
284            }
285            $waitPos = implode( ',', $gtidsWait );
286        } else {
287            // Wait on the binlog coordinates
288            $encFile = $conn->addQuotes( $pos->getLogFile() );
289            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
290            $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] );
291            $query = new Query(
292                "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)",
293                ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE,
294                'SELECT',
295                null,
296                "SELECT MASTER_POS_WAIT(?, ?, ?)"
297            );
298            $waitPos = $pos->__toString();
299        }
300
301        $start = ConvertibleTimestamp::hrtime();
302        $res = $conn->query( $query, __METHOD__ );
303        $row = $res->fetchRow();
304        $seconds = ( ConvertibleTimestamp::hrtime() - $start ) / 1e9;
305
306        // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
307        $status = ( $row[0] !== null ) ? intval( $row[0] ) : null;
308        if ( $status === null ) {
309            $this->logger->error(
310                "An error occurred while waiting for replication to reach {wait_pos}",
311                $this->getLogContext( $conn, [
312                    'raw_pos' => $pos,
313                    'wait_pos' => $waitPos,
314                    'sql' => $query->getSQL(),
315                    'seconds_waited' => $seconds,
316                    'exception' => new RuntimeException()
317                ] )
318            );
319        } elseif ( $status < 0 ) {
320            $this->logger->info(
321                "Timed out waiting for replication to reach {wait_pos}",
322                $this->getLogContext( $conn, [
323                    'raw_pos' => $pos,
324                    'wait_pos' => $waitPos,
325                    'timeout' => $timeout,
326                    'sql' => $query->getSQL(),
327                    'seconds_waited' => $seconds,
328                ] )
329            );
330        } elseif ( $status >= 0 ) {
331            $this->logger->debug(
332                "Replication has reached {wait_pos}",
333                $this->getLogContext( $conn, [
334                    'raw_pos' => $pos,
335                    'wait_pos' => $waitPos,
336                    'seconds_waited' => $seconds,
337                ] )
338            );
339            // Remember that this position was reached to save queries next time
340            $this->lastKnownReplicaPos = $pos;
341        }
342
343        return $status;
344    }
345
346    /**
347     * Get the position of the primary DB from SHOW SLAVE STATUS
348     *
349     * @param IDatabase $conn To make queries
350     * @return MySQLPrimaryPos|false
351     */
352    public function getReplicaPos( IDatabase $conn ) {
353        $now = microtime( true ); // as-of-time *before* fetching GTID variables
354
355        if ( $this->useGTIDs() ) {
356            // Try to use GTIDs, fallbacking to binlog positions if not possible
357            $data = $this->getServerGTIDs( $conn, __METHOD__ );
358            // Use gtid_slave_pos for MariaDB and gtid_executed for MySQL
359            foreach ( [ 'gtid_slave_pos', 'gtid_executed' ] as $name ) {
360                if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
361                    return new MySQLPrimaryPos( $data[$name], $now );
362                }
363            }
364        }
365
366        $data = $this->getServerRoleStatus( $conn, 'SLAVE', __METHOD__ );
367        if ( $data && strlen( $data['Relay_Master_Log_File'] ) ) {
368            return new MySQLPrimaryPos(
369                "{$data['Relay_Master_Log_File']}/{$data['Exec_Master_Log_Pos']}",
370                $now
371            );
372        }
373
374        return false;
375    }
376
377    /**
378     * Get the position of the primary DB from SHOW MASTER STATUS
379     *
380     * @param IDatabase $conn To make queries
381     * @return MySQLPrimaryPos|false
382     */
383    public function getPrimaryPos( IDatabase $conn ) {
384        $now = microtime( true ); // as-of-time *before* fetching GTID variables
385
386        $pos = false;
387        if ( $this->useGTIDs() ) {
388            // Try to use GTIDs, fallbacking to binlog positions if not possible
389            $data = $this->getServerGTIDs( $conn, __METHOD__ );
390            // Use gtid_binlog_pos for MariaDB and gtid_executed for MySQL
391            foreach ( [ 'gtid_binlog_pos', 'gtid_executed' ] as $name ) {
392                if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
393                    $pos = new MySQLPrimaryPos( $data[$name], $now );
394                    break;
395                }
396            }
397            // Filter domains that are inactive or not relevant to the session
398            if ( $pos ) {
399                $pos->setActiveOriginServerId( $this->getServerId( $conn ) );
400                $pos->setActiveOriginServerUUID( $this->getServerUUID( $conn ) );
401                if ( isset( $data['gtid_domain_id'] ) ) {
402                    $pos->setActiveDomain( $data['gtid_domain_id'] );
403                }
404            }
405        }
406
407        if ( !$pos ) {
408            $data = $this->getServerRoleStatus( $conn, 'MASTER', __METHOD__ );
409            if ( $data && strlen( $data['File'] ) ) {
410                $pos = new MySQLPrimaryPos( "{$data['File']}/{$data['Position']}", $now );
411            }
412        }
413
414        return $pos;
415    }
416
417    /**
418     * @param IDatabase $conn To make queries
419     * @return string Value of server_id (32-bit integer, unique to the replication topology)
420     * @throws DBQueryError
421     */
422    protected function getServerId( IDatabase $conn ) {
423        $fname = __METHOD__;
424        return $this->srvCache->getWithSetCallback(
425            $this->srvCache->makeGlobalKey( 'mysql-server-id', $conn->getServerName() ),
426            self::SERVER_ID_CACHE_TTL,
427            static function () use ( $conn, $fname ) {
428                $query = new Query(
429                    "SELECT @@server_id AS id",
430                    ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE,
431                    'SELECT',
432                    null,
433                    "SELECT @@server_id AS id"
434                );
435                $res = $conn->query( $query, $fname );
436
437                return $res->fetchObject()->id;
438            }
439        );
440    }
441
442    /**
443     * @param IDatabase $conn To make queries
444     * @return string|null Value of server_uuid (hyphenated 128-bit hex string, globally unique)
445     * @throws DBQueryError
446     */
447    protected function getServerUUID( IDatabase $conn ) {
448        $fname = __METHOD__;
449        return $this->srvCache->getWithSetCallback(
450            $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $conn->getServerName() ),
451            self::SERVER_ID_CACHE_TTL,
452            static function () use ( $conn, $fname ) {
453                $query = new Query(
454                    "SHOW GLOBAL VARIABLES LIKE 'server_uuid'",
455                    ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE,
456                    'SHOW',
457                    null,
458                    "SHOW GLOBAL VARIABLES LIKE 'server_uuid'"
459                );
460                $res = $conn->query( $query, $fname );
461                $row = $res->fetchObject();
462
463                return $row ? $row->Value : null;
464            }
465        );
466    }
467
468    /**
469     * @param IDatabase $conn To make queries
470     * @param string $fname
471     * @return string[]
472     */
473    protected function getServerGTIDs( IDatabase $conn, $fname ) {
474        $map = [];
475
476        $flags = ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE;
477
478        // Get global-only variables like gtid_executed
479        $query = new Query(
480            "SHOW GLOBAL VARIABLES LIKE 'gtid_%'",
481            $flags,
482            'SHOW',
483            null,
484            "SHOW GLOBAL VARIABLES LIKE 'gtid_%'"
485        );
486        $res = $conn->query( $query, $fname );
487        foreach ( $res as $row ) {
488            $map[$row->Variable_name] = $row->Value;
489        }
490        // Get session-specific (e.g. gtid_domain_id since that is were writes will log)
491        $query = new Query(
492            "SHOW SESSION VARIABLES LIKE 'gtid_%'",
493            $flags,
494            'SHOW',
495            null,
496            "SHOW SESSION VARIABLES LIKE 'gtid_%'"
497        );
498        $res = $conn->query( $query, $fname );
499        foreach ( $res as $row ) {
500            $map[$row->Variable_name] = $row->Value;
501        }
502
503        return $map;
504    }
505
506    /**
507     * @param IDatabase $conn To make queries
508     * @param string $role One of "MASTER"/"SLAVE"
509     * @param string $fname
510     * @return array<string,mixed>|null Latest available server status row; false on failure
511     */
512    protected function getServerRoleStatus( IDatabase $conn, $role, $fname ) {
513        $query = new Query(
514            "SHOW $role STATUS",
515            ISQLPlatform::QUERY_SILENCE_ERRORS | ISQLPlatform::QUERY_IGNORE_DBO_TRX | ISQLPlatform::QUERY_CHANGE_NONE,
516            'SHOW',
517            null,
518            "SHOW $role STATUS"
519        );
520        $res = $conn->query( $query, $fname );
521        $row = $res ? $res->fetchRow() : false;
522
523        return ( $row ?: null );
524    }
525
526}