rdbms: make MySQLMasterPos handle inactive GTIDs
[lhc/web/wiklou.git] / includes / libs / rdbms / database / position / MySQLMasterPos.php
1 <?php
2
3 namespace Wikimedia\Rdbms;
4
5 use InvalidArgumentException;
6
7 /**
8 * DBMasterPos class for MySQL/MariaDB
9 *
10 * Note that master positions and sync logic here make some assumptions:
11 * - Binlog-based usage assumes single-source replication and non-hierarchical replication.
12 * - GTID-based usage allows getting/syncing with multi-source replication. It is assumed
13 * that GTID sets are complete (e.g. include all domains on the server).
14 */
15 class MySQLMasterPos implements DBMasterPos {
16 /** @var string|null Binlog file base name */
17 public $binlog;
18 /** @var int[]|null Binglog file position tuple */
19 public $pos;
20 /** @var string[] GTID list */
21 public $gtids = [];
22 /** @var float UNIX timestamp */
23 public $asOfTime = 0.0;
24
25 /**
26 * @param string $position One of (comma separated GTID list, <binlog file>/<integer>)
27 * @param float $asOfTime UNIX timestamp
28 */
29 public function __construct( $position, $asOfTime ) {
30 $m = [];
31 if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', $position, $m ) ) {
32 $this->binlog = $m[1]; // ideally something like host name
33 $this->pos = [ (int)$m[2], (int)$m[3] ];
34 } else {
35 $this->gtids = array_map( 'trim', explode( ',', $position ) );
36 if ( !$this->gtids ) {
37 throw new InvalidArgumentException( "GTID set should not be empty." );
38 }
39 }
40
41 $this->asOfTime = $asOfTime;
42 }
43
44 public function asOfTime() {
45 return $this->asOfTime;
46 }
47
48 public function hasReached( DBMasterPos $pos ) {
49 if ( !( $pos instanceof self ) ) {
50 throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
51 }
52
53 // Prefer GTID comparisons, which work with multi-tier replication
54 $thisPosByDomain = $this->getGtidCoordinates();
55 $thatPosByDomain = $pos->getGtidCoordinates();
56 if ( $thisPosByDomain && $thatPosByDomain ) {
57 $comparisons = [];
58 // Check that this has positions reaching those in $pos for all domains in common
59 foreach ( $thatPosByDomain as $domain => $thatPos ) {
60 if ( isset( $thisPosByDomain[$domain] ) ) {
61 $comparisons[] = ( $thatPos <= $thisPosByDomain[$domain] );
62 }
63 }
64 // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
65 // quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot
66 // be cleaned up. Assume that the domains in both this and $pos cover the relevant
67 // active channels.
68 return ( $comparisons && !in_array( false, $comparisons, true ) );
69 }
70
71 // Fallback to the binlog file comparisons
72 $thisBinPos = $this->getBinlogCoordinates();
73 $thatBinPos = $pos->getBinlogCoordinates();
74 if ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] ) {
75 return ( $thisBinPos['pos'] >= $thatBinPos['pos'] );
76 }
77
78 // Comparing totally different binlogs does not make sense
79 return false;
80 }
81
82 public function channelsMatch( DBMasterPos $pos ) {
83 if ( !( $pos instanceof self ) ) {
84 throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
85 }
86
87 // Prefer GTID comparisons, which work with multi-tier replication
88 $thisPosDomains = array_keys( $this->getGtidCoordinates() );
89 $thatPosDomains = array_keys( $pos->getGtidCoordinates() );
90 if ( $thisPosDomains && $thatPosDomains ) {
91 // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
92 // quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot
93 // easily be cleaned up. Assume that the domains in both this and $pos cover the
94 // relevant active channels.
95 return array_intersect( $thatPosDomains, $thisPosDomains ) ? true : false;
96 }
97
98 // Fallback to the binlog file comparisons
99 $thisBinPos = $this->getBinlogCoordinates();
100 $thatBinPos = $pos->getBinlogCoordinates();
101
102 return ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] );
103 }
104
105 /**
106 * @return string|null
107 */
108 public function getLogFile() {
109 return $this->gtids ? null : "{$this->binlog}.{$this->pos[0]}";
110 }
111
112 /**
113 * @return string GTID set or <binlog file>/<position> (e.g db1034-bin.000976/843431247)
114 */
115 public function __toString() {
116 return $this->gtids
117 ? implode( ',', $this->gtids )
118 : $this->getLogFile() . "/{$this->pos[1]}";
119 }
120
121 /**
122 * @note: this returns false for multi-source replication GTID sets
123 * @see https://mariadb.com/kb/en/mariadb/gtid
124 * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
125 * @return array Map of (domain => integer position); possibly empty
126 */
127 protected function getGtidCoordinates() {
128 $gtidInfos = [];
129 foreach ( $this->gtids as $gtid ) {
130 $m = [];
131 // MariaDB style: <domain>-<server id>-<sequence number>
132 if ( preg_match( '!^(\d+)-\d+-(\d+)$!', $gtid, $m ) ) {
133 $gtidInfos[(int)$m[1]] = (int)$m[2];
134 // MySQL style: <UUID domain>:<sequence number>
135 } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(\d+)$!', $gtid, $m ) ) {
136 $gtidInfos[$m[1]] = (int)$m[2];
137 } else {
138 $gtidInfos = [];
139 break; // unrecognized GTID
140 }
141
142 }
143
144 return $gtidInfos;
145 }
146
147 /**
148 * @see https://dev.mysql.com/doc/refman/5.7/en/show-master-status.html
149 * @see https://dev.mysql.com/doc/refman/5.7/en/show-slave-status.html
150 * @return array|bool (binlog, (integer file number, integer position)) or false
151 */
152 protected function getBinlogCoordinates() {
153 return ( $this->binlog !== null && $this->pos !== null )
154 ? [ 'binlog' => $this->binlog, 'pos' => $this->pos ]
155 : false;
156 }
157 }