54eca79a44c5890bdb88e788f3631f05e63555bf
3 namespace Wikimedia\Rdbms
;
5 use InvalidArgumentException
;
6 use UnexpectedValueException
;
9 * DBMasterPos class for MySQL/MariaDB
11 * Note that master positions and sync logic here make some assumptions:
12 * - Binlog-based usage assumes single-source replication and non-hierarchical replication.
13 * - GTID-based usage allows getting/syncing with multi-source replication. It is assumed
14 * that GTID sets are complete (e.g. include all domains on the server).
16 * @see https://mariadb.com/kb/en/library/gtid/
17 * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
19 class MySQLMasterPos
implements DBMasterPos
{
20 /** @var int One of (BINARY_LOG, GTID_MYSQL, GTID_MARIA) */
22 /** @var string|null Base name of all Binary Log files */
24 /** @var int[]|null Binary Log position tuple (index number, event number) */
26 /** @var string[] Map of (server_uuid/gtid_domain_id => GTID) */
28 /** @var int|null Active GTID domain ID */
29 private $activeDomain;
30 /** @var int|null ID of the server were DB writes originate */
31 private $activeServerId;
32 /** @var string|null UUID of the server were DB writes originate */
33 private $activeServerUUID;
34 /** @var float UNIX timestamp */
35 private $asOfTime = 0.0;
37 const BINARY_LOG
= 'binary-log';
38 const GTID_MARIA
= 'gtid-maria';
39 const GTID_MYSQL
= 'gtid-mysql';
41 /** @var int Key name of the binary log index number of a position tuple */
43 /** @var int Key name of the binary log event number of a position tuple */
47 * @param string $position One of (comma separated GTID list, <binlog file>/<integer>)
48 * @param float $asOfTime UNIX timestamp
50 public function __construct( $position, $asOfTime ) {
51 $this->init( $position, $asOfTime );
55 * @param string $position
56 * @param float $asOfTime
58 protected function init( $position, $asOfTime ) {
60 if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', $position, $m ) ) {
61 $this->binLog
= $m[1]; // ideally something like host name
62 $this->logPos
= [ self
::CORD_INDEX
=> (int)$m[2], self
::CORD_EVENT
=> (int)$m[3] ];
63 $this->style
= self
::BINARY_LOG
;
65 $gtids = array_filter( array_map( 'trim', explode( ',', $position ) ) );
66 foreach ( $gtids as $gtid ) {
67 $components = self
::parseGTID( $gtid );
69 throw new InvalidArgumentException( "Invalid GTID '$gtid'." );
72 list( $domain, $pos ) = $components;
73 if ( isset( $this->gtids
[$domain] ) ) {
74 // For MySQL, handle the case where some past issue caused a gap in the
75 // executed GTID set, e.g. [last_purged+1,N-1] and [N+1,N+2+K]. Ignore the
76 // gap by using the GTID with the highest ending sequence number.
77 list( , $otherPos ) = self
::parseGTID( $this->gtids
[$domain] );
78 if ( $pos > $otherPos ) {
79 $this->gtids
[$domain] = $gtid;
82 $this->gtids
[$domain] = $gtid;
85 if ( is_int( $domain ) ) {
86 $this->style
= self
::GTID_MARIA
; // gtid_domain_id
88 $this->style
= self
::GTID_MYSQL
; // server_uuid
91 if ( !$this->gtids
) {
92 throw new InvalidArgumentException( "GTID set cannot be empty." );
96 $this->asOfTime
= $asOfTime;
99 public function asOfTime() {
100 return $this->asOfTime
;
103 public function hasReached( DBMasterPos
$pos ) {
104 if ( !( $pos instanceof self
) ) {
105 throw new InvalidArgumentException( "Position not an instance of " . __CLASS__
);
108 // Prefer GTID comparisons, which work with multi-tier replication
109 $thisPosByDomain = $this->getActiveGtidCoordinates();
110 $thatPosByDomain = $pos->getActiveGtidCoordinates();
111 if ( $thisPosByDomain && $thatPosByDomain ) {
113 // Check that this has positions reaching those in $pos for all domains in common
114 foreach ( $thatPosByDomain as $domain => $thatPos ) {
115 if ( isset( $thisPosByDomain[$domain] ) ) {
116 $comparisons[] = ( $thatPos <= $thisPosByDomain[$domain] );
119 // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
120 // quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot
121 // be cleaned up. Assume that the domains in both this and $pos cover the relevant
123 return ( $comparisons && !in_array( false, $comparisons, true ) );
126 // Fallback to the binlog file comparisons
127 $thisBinPos = $this->getBinlogCoordinates();
128 $thatBinPos = $pos->getBinlogCoordinates();
129 if ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] ) {
130 return ( $thisBinPos['pos'] >= $thatBinPos['pos'] );
133 // Comparing totally different binlogs does not make sense
137 public function channelsMatch( DBMasterPos
$pos ) {
138 if ( !( $pos instanceof self
) ) {
139 throw new InvalidArgumentException( "Position not an instance of " . __CLASS__
);
142 // Prefer GTID comparisons, which work with multi-tier replication
143 $thisPosDomains = array_keys( $this->getActiveGtidCoordinates() );
144 $thatPosDomains = array_keys( $pos->getActiveGtidCoordinates() );
145 if ( $thisPosDomains && $thatPosDomains ) {
146 // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
147 // quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot
148 // easily be cleaned up. Assume that the domains in both this and $pos cover the
149 // relevant active channels.
150 return array_intersect( $thatPosDomains, $thisPosDomains ) ?
true : false;
153 // Fallback to the binlog file comparisons
154 $thisBinPos = $this->getBinlogCoordinates();
155 $thatBinPos = $pos->getBinlogCoordinates();
157 return ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] );
161 * @return string|null Base name of binary log files
164 public function getLogName() {
165 return $this->gtids ?
null : $this->binLog
;
169 * @return int[]|null Tuple of (binary log file number, event number)
172 public function getLogPosition() {
173 return $this->gtids ?
null : $this->logPos
;
177 * @return string|null Name of the binary log file for this position
180 public function getLogFile() {
181 return $this->gtids ?
null : "{$this->binLog}.{$this->logPos[self::CORD_INDEX]}";
185 * @return string[] Map of (server_uuid/gtid_domain_id => GTID)
188 public function getGTIDs() {
193 * @param int|null $id @@gtid_domain_id of the active replication stream
196 public function setActiveDomain( $id ) {
197 $this->activeDomain
= (int)$id;
201 * @param int|null $id @@server_id of the server were writes originate
204 public function setActiveOriginServerId( $id ) {
205 $this->activeServerId
= (int)$id;
209 * @param string|null $id @@server_uuid of the server were writes originate
212 public function setActiveOriginServerUUID( $id ) {
213 $this->activeServerUUID
= $id;
217 * @param MySQLMasterPos $pos
218 * @param MySQLMasterPos $refPos
219 * @return string[] List of GTIDs from $pos that have domains in $refPos
222 public static function getCommonDomainGTIDs( MySQLMasterPos
$pos, MySQLMasterPos
$refPos ) {
224 array_intersect_key( $pos->gtids
, $refPos->getActiveGtidCoordinates() )
229 * @see https://mariadb.com/kb/en/mariadb/gtid
230 * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
231 * @return array Map of (server_uuid/gtid_domain_id => integer position); possibly empty
233 protected function getActiveGtidCoordinates() {
236 foreach ( $this->gtids
as $domain => $gtid ) {
237 list( $domain, $pos, $server ) = self
::parseGTID( $gtid );
240 // Filter out GTIDs from non-active replication domains
241 if ( $this->style
=== self
::GTID_MARIA
&& $this->activeDomain
!== null ) {
242 $ignore |
= ( $domain !== $this->activeDomain
);
244 // Likewise for GTIDs from non-active replication origin servers
245 if ( $this->style
=== self
::GTID_MARIA
&& $this->activeServerId
!== null ) {
246 $ignore |
= ( $server !== $this->activeServerId
);
247 } elseif ( $this->style
=== self
::GTID_MYSQL
&& $this->activeServerUUID
!== null ) {
248 $ignore |
= ( $server !== $this->activeServerUUID
);
252 $gtidInfos[$domain] = $pos;
260 * @param string $id GTID
261 * @return array|null [domain ID or server UUID, sequence number, server ID/UUID] or null
263 protected static function parseGTID( $id ) {
265 if ( preg_match( '!^(\d+)-(\d+)-(\d+)$!', $id, $m ) ) {
266 // MariaDB style: <domain>-<server id>-<sequence number>
267 return [ (int)$m[1], (int)$m[3], (int)$m[2] ];
268 } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(?:\d+-|)(\d+)$!', $id, $m ) ) {
269 // MySQL style: <server UUID>:<sequence number>-<sequence number>
270 // Normally, the first number should reflect the point (gtid_purged) where older
271 // binary logs where purged to save space. When doing comparisons, it may as well
272 // be 1 in that case. Assume that this is generally the situation.
273 return [ $m[1], (int)$m[2], $m[1] ];
280 * @see https://dev.mysql.com/doc/refman/5.7/en/show-master-status.html
281 * @see https://dev.mysql.com/doc/refman/5.7/en/show-slave-status.html
282 * @return array|bool Map of (binlog:<string>, pos:(<integer>, <integer>)) or false
284 protected function getBinlogCoordinates() {
285 return ( $this->binLog
!== null && $this->logPos
!== null )
286 ?
[ 'binlog' => $this->binLog
, 'pos' => $this->logPos
]
290 public function serialize() {
292 'position' => $this->__toString(),
293 'activeDomain' => $this->activeDomain
,
294 'activeServerId' => $this->activeServerId
,
295 'activeServerUUID' => $this->activeServerUUID
,
296 'asOfTime' => $this->asOfTime
300 public function unserialize( $serialized ) {
301 $data = unserialize( $serialized );
302 if ( !is_array( $data ) ) {
303 throw new UnexpectedValueException( __METHOD__
. ": cannot unserialize position" );
306 $this->init( $data['position'], $data['asOfTime'] );
307 if ( isset( $data['activeDomain'] ) ) {
308 $this->setActiveDomain( $data['activeDomain'] );
310 if ( isset( $data['activeServerId'] ) ) {
311 $this->setActiveOriginServerId( $data['activeServerId'] );
313 if ( isset( $data['activeServerUUID'] ) ) {
314 $this->setActiveOriginServerUUID( $data['activeServerUUID'] );
319 * @return string GTID set or <binary log file>/<position> (e.g db1034-bin.000976/843431247)
321 public function __toString() {
323 ?
implode( ',', $this->gtids
)
324 : $this->getLogFile() . "/{$this->logPos[self::CORD_EVENT]}";