3 namespace Wikimedia\Rdbms
;
5 use InvalidArgumentException
;
9 * DBPrimaryPos implementation for MySQL and MariaDB.
11 * Note that primary positions and sync logic here make some assumptions:
13 * - Binlog-based usage assumes single-source replication and non-hierarchical replication.
14 * - GTID-based usage allows getting/syncing with multi-source replication. It is assumed
15 * that GTID sets are complete (e.g. include all domains on the server).
17 * @see https://mariadb.com/kb/en/library/gtid/
18 * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
21 class MySQLPrimaryPos
implements Stringable
, DBPrimaryPos
{
22 /** @var string One of (BINARY_LOG, GTID_MYSQL, GTID_MARIA) */
24 /** @var string|null Base name of all Binary Log files */
26 /** @var array<int,int|string>|null Binary Log position tuple (index number, event number) */
28 /** @var string[] Map of (server_uuid/gtid_domain_id => GTID) */
30 /** @var string|null Active GTID domain ID */
31 private $activeDomain;
32 /** @var string|null ID of the server were DB writes originate */
33 private $activeServerId;
34 /** @var string|null UUID of the server were DB writes originate */
35 private $activeServerUUID;
36 /** @var float UNIX timestamp */
37 private $asOfTime = 0.0;
39 private const BINARY_LOG
= 'binary-log';
40 private const GTID_MARIA
= 'gtid-maria';
41 private const GTID_MYSQL
= 'gtid-mysql';
43 /** Key name of the 6 digit binary log index number of a position tuple */
44 public const CORD_INDEX
= 0;
45 /** Key name of the 64 bit binary log event number of a position tuple */
46 public const CORD_EVENT
= 1;
49 * @param string $position One of (comma separated GTID list, <binlog file>/<64 bit integer>)
50 * @param float $asOfTime UNIX timestamp
52 public function __construct( $position, $asOfTime ) {
53 $this->init( $position, $asOfTime );
57 * @param string $position
58 * @param float $asOfTime
60 protected function init( $position, $asOfTime ) {
62 if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', $position, $m ) ) {
63 $this->binLog
= $m[1]; // ideally something like host name
64 $this->logPos
= [ self
::CORD_INDEX
=> (int)$m[2], self
::CORD_EVENT
=> $m[3] ];
65 $this->style
= self
::BINARY_LOG
;
67 $gtids = array_filter( array_map( 'trim', explode( ',', $position ) ) );
68 foreach ( $gtids as $gtid ) {
69 $components = self
::parseGTID( $gtid );
71 throw new InvalidArgumentException( "Invalid GTID '$gtid'." );
74 [ $domain, $eventNumber ] = $components;
75 if ( isset( $this->gtids
[$domain] ) ) {
76 // For MySQL, handle the case where some past issue caused a gap in the
77 // executed GTID set, e.g. [last_purged+1,N-1] and [N+1,N+2+K]. Ignore the
78 // gap by using the GTID with the highest ending event number.
79 [ , $otherEventNumber ] = self
::parseGTID( $this->gtids
[$domain] );
80 if ( $eventNumber > $otherEventNumber ) {
81 $this->gtids
[$domain] = $gtid;
84 $this->gtids
[$domain] = $gtid;
87 if ( is_string( $domain ) ) {
88 $this->style
= self
::GTID_MARIA
; // gtid_domain_id
90 $this->style
= self
::GTID_MYSQL
; // server_uuid
93 if ( !$this->gtids
) {
94 throw new InvalidArgumentException( "GTID set cannot be empty." );
98 $this->asOfTime
= $asOfTime;
101 public function asOfTime() {
102 return $this->asOfTime
;
105 public function hasReached( DBPrimaryPos
$pos ) {
106 if ( !( $pos instanceof self
) ) {
107 throw new InvalidArgumentException( "Position not an instance of " . __CLASS__
);
110 // Prefer GTID comparisons, which work with multi-tier replication
111 $thisPosByDomain = $this->getActiveGtidCoordinates();
112 $thatPosByDomain = $pos->getActiveGtidCoordinates();
113 if ( $thisPosByDomain && $thatPosByDomain ) {
115 // Check that this has positions reaching those in $pos for all domains in common
116 foreach ( $thatPosByDomain as $domain => $thatPos ) {
117 if ( isset( $thisPosByDomain[$domain] ) ) {
118 $comparisons[] = ( $thatPos <= $thisPosByDomain[$domain] );
121 // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
122 // quirks, prior primary switch-overs may result in inactive garbage GTIDs that cannot
123 // be cleaned up. Assume that the domains in both this and $pos cover the relevant
125 return ( $comparisons && !in_array( false, $comparisons, true ) );
128 // Fallback to the binlog file comparisons
129 $thisBinPos = $this->getBinlogCoordinates();
130 $thatBinPos = $pos->getBinlogCoordinates();
131 if ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] ) {
132 return ( $thisBinPos['pos'] >= $thatBinPos['pos'] );
135 // Comparing totally different binlogs does not make sense
140 * @return array<int,int|string>|null Tuple of (binary log file number, 64 bit event number)
143 public function getLogPosition() {
144 return $this->gtids ?
null : $this->logPos
;
148 * @return string|null Name of the binary log file for this position
151 public function getLogFile() {
152 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
153 return $this->gtids ?
null : "{$this->binLog}.{$this->logPos[self::CORD_INDEX]}";
157 * @return array<string,string> Map of (server_uuid/gtid_domain_id => GTID)
160 public function getGTIDs() {
165 * Set the GTID domain known to be used in new commits on a replication stream of interest
167 * This makes getRelevantActiveGTIDs() filter out GTIDs from other domains
169 * @see MySQLPrimaryPos::getRelevantActiveGTIDs()
170 * @see https://mariadb.com/kb/en/library/gtid/#gtid_domain_id
172 * @param string|int|null $id @@gtid_domain_id of the active replication stream
173 * @return MySQLPrimaryPos This instance (since 1.34)
176 public function setActiveDomain( $id ) {
177 $this->activeDomain
= (string)$id;
183 * Set the server ID known to be used in new commits on a replication stream of interest
185 * This makes getRelevantActiveGTIDs() filter out GTIDs from other origin servers
187 * @see MySQLPrimaryPos::getRelevantActiveGTIDs()
189 * @param string|int|null $id @@server_id of the server were writes originate
190 * @return MySQLPrimaryPos This instance (since 1.34)
193 public function setActiveOriginServerId( $id ) {
194 $this->activeServerId
= (string)$id;
200 * Set the server UUID known to be used in new commits on a replication stream of interest
202 * This makes getRelevantActiveGTIDs() filter out GTIDs from other origin servers
204 * @see MySQLPrimaryPos::getRelevantActiveGTIDs()
206 * @param string|null $id @@server_uuid of the server were writes originate
207 * @return MySQLPrimaryPos This instance (since 1.34)
210 public function setActiveOriginServerUUID( $id ) {
211 $this->activeServerUUID
= $id;
217 * @param MySQLPrimaryPos $pos
218 * @param MySQLPrimaryPos $refPos
219 * @return string[] List of active GTIDs from $pos that have domains in $refPos
222 public static function getRelevantActiveGTIDs( MySQLPrimaryPos
$pos, MySQLPrimaryPos
$refPos ) {
223 return array_values( array_intersect_key(
225 $pos->getActiveGtidCoordinates(),
231 * @see https://mariadb.com/kb/en/mariadb/gtid
232 * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
233 * @return array<string,int> Map of (server_uuid/gtid_domain_id => integer position)
235 protected function getActiveGtidCoordinates() {
238 foreach ( $this->gtids
as $gtid ) {
239 [ $domain, $pos, $server ] = self
::parseGTID( $gtid );
242 // Filter out GTIDs from non-active replication domains
243 if ( $this->style
=== self
::GTID_MARIA
&& $this->activeDomain
!== null ) {
244 $ignore = $ignore ||
( $domain !== $this->activeDomain
);
246 // Likewise for GTIDs from non-active replication origin servers
247 if ( $this->style
=== self
::GTID_MARIA
&& $this->activeServerId
!== null ) {
248 $ignore = $ignore ||
( $server !== $this->activeServerId
);
249 } elseif ( $this->style
=== self
::GTID_MYSQL
&& $this->activeServerUUID
!== null ) {
250 $ignore = $ignore ||
( $server !== $this->activeServerUUID
);
254 $gtidInfos[$domain] = $pos;
262 * @param string $id GTID
263 * @return string[]|null (domain ID, event number, source server ID) for MariaDB,
264 * (source server UUID, event number, source server UUID) for MySQL, or null
266 protected static function parseGTID( $id ) {
268 if ( preg_match( '!^(\d+)-(\d+)-(\d+)$!', $id, $m ) ) {
269 // MariaDB style: "<32 bit domain ID>-<32 bit server id>-<64 bit event number>"
271 $originServerId = $m[2];
272 $eventNumber = $m[3];
273 } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(?:\d+-|)(\d+)$!', $id, $m ) ) {
274 // MySQL style: "<server UUID>:<64 bit event number>[-<64 bit event number>]".
275 // Normally, the first number should reflect the point (gtid_purged) where older
276 // binary logs where purged to save space. When doing comparisons, it may as well
277 // be 1 in that case. Assume that this is generally the situation.
279 $originServerId = $m[1];
280 $eventNumber = $m[2];
285 return [ $channelId, $eventNumber, $originServerId ];
289 * @see https://dev.mysql.com/doc/refman/5.7/en/show-master-status.html
290 * @see https://dev.mysql.com/doc/refman/5.7/en/show-slave-status.html
291 * @return array|false Map of (binlog:<string>, pos:(<integer>, <integer>)) or false
293 protected function getBinlogCoordinates() {
294 return ( $this->binLog
!== null && $this->logPos
!== null )
295 ?
[ 'binlog' => $this->binLog
, 'pos' => $this->logPos
]
299 public static function newFromArray( array $data ) {
300 $pos = new self( $data['position'], $data['asOfTime'] );
302 if ( isset( $data['activeDomain'] ) ) {
303 $pos->setActiveDomain( $data['activeDomain'] );
305 if ( isset( $data['activeServerId'] ) ) {
306 $pos->setActiveOriginServerId( $data['activeServerId'] );
308 if ( isset( $data['activeServerUUID'] ) ) {
309 $pos->setActiveOriginServerUUID( $data['activeServerUUID'] );
314 public function toArray(): array {
316 '_type_' => get_class( $this ),
317 'position' => $this->__toString(),
318 'activeDomain' => $this->activeDomain
,
319 'activeServerId' => $this->activeServerId
,
320 'activeServerUUID' => $this->activeServerUUID
,
321 'asOfTime' => $this->asOfTime
326 * @return string GTID set or <binary log file>/<position> (e.g db1034-bin.000976/843431247)
328 public function __toString() {
330 ?
implode( ',', $this->gtids
)
331 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
332 : $this->getLogFile() . "/{$this->logPos[self::CORD_EVENT]}";