MySQLMasterPos.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. <?php
  2. namespace Wikimedia\Rdbms;
  3. use InvalidArgumentException;
  4. use UnexpectedValueException;
  5. /**
  6. * DBMasterPos class for MySQL/MariaDB
  7. *
  8. * Note that master positions and sync logic here make some assumptions:
  9. * - Binlog-based usage assumes single-source replication and non-hierarchical replication.
  10. * - GTID-based usage allows getting/syncing with multi-source replication. It is assumed
  11. * that GTID sets are complete (e.g. include all domains on the server).
  12. *
  13. * @see https://mariadb.com/kb/en/library/gtid/
  14. * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
  15. */
  16. class MySQLMasterPos implements DBMasterPos {
  17. /** @var string One of (BINARY_LOG, GTID_MYSQL, GTID_MARIA) */
  18. private $style;
  19. /** @var string|null Base name of all Binary Log files */
  20. private $binLog;
  21. /** @var int[]|null Binary Log position tuple (index number, event number) */
  22. private $logPos;
  23. /** @var string[] Map of (server_uuid/gtid_domain_id => GTID) */
  24. private $gtids = [];
  25. /** @var int|null Active GTID domain ID */
  26. private $activeDomain;
  27. /** @var int|null ID of the server were DB writes originate */
  28. private $activeServerId;
  29. /** @var string|null UUID of the server were DB writes originate */
  30. private $activeServerUUID;
  31. /** @var float UNIX timestamp */
  32. private $asOfTime = 0.0;
  33. const BINARY_LOG = 'binary-log';
  34. const GTID_MARIA = 'gtid-maria';
  35. const GTID_MYSQL = 'gtid-mysql';
  36. /** @var int Key name of the binary log index number of a position tuple */
  37. const CORD_INDEX = 0;
  38. /** @var int Key name of the binary log event number of a position tuple */
  39. const CORD_EVENT = 1;
  40. /**
  41. * @param string $position One of (comma separated GTID list, <binlog file>/<integer>)
  42. * @param float $asOfTime UNIX timestamp
  43. */
  44. public function __construct( $position, $asOfTime ) {
  45. $this->init( $position, $asOfTime );
  46. }
  47. /**
  48. * @param string $position
  49. * @param float $asOfTime
  50. */
  51. protected function init( $position, $asOfTime ) {
  52. $m = [];
  53. if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', $position, $m ) ) {
  54. $this->binLog = $m[1]; // ideally something like host name
  55. $this->logPos = [ self::CORD_INDEX => (int)$m[2], self::CORD_EVENT => (int)$m[3] ];
  56. $this->style = self::BINARY_LOG;
  57. } else {
  58. $gtids = array_filter( array_map( 'trim', explode( ',', $position ) ) );
  59. foreach ( $gtids as $gtid ) {
  60. $components = self::parseGTID( $gtid );
  61. if ( !$components ) {
  62. throw new InvalidArgumentException( "Invalid GTID '$gtid'." );
  63. }
  64. list( $domain, $pos ) = $components;
  65. if ( isset( $this->gtids[$domain] ) ) {
  66. // For MySQL, handle the case where some past issue caused a gap in the
  67. // executed GTID set, e.g. [last_purged+1,N-1] and [N+1,N+2+K]. Ignore the
  68. // gap by using the GTID with the highest ending sequence number.
  69. list( , $otherPos ) = self::parseGTID( $this->gtids[$domain] );
  70. if ( $pos > $otherPos ) {
  71. $this->gtids[$domain] = $gtid;
  72. }
  73. } else {
  74. $this->gtids[$domain] = $gtid;
  75. }
  76. if ( is_int( $domain ) ) {
  77. $this->style = self::GTID_MARIA; // gtid_domain_id
  78. } else {
  79. $this->style = self::GTID_MYSQL; // server_uuid
  80. }
  81. }
  82. if ( !$this->gtids ) {
  83. throw new InvalidArgumentException( "GTID set cannot be empty." );
  84. }
  85. }
  86. $this->asOfTime = $asOfTime;
  87. }
  88. public function asOfTime() {
  89. return $this->asOfTime;
  90. }
  91. public function hasReached( DBMasterPos $pos ) {
  92. if ( !( $pos instanceof self ) ) {
  93. throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
  94. }
  95. // Prefer GTID comparisons, which work with multi-tier replication
  96. $thisPosByDomain = $this->getActiveGtidCoordinates();
  97. $thatPosByDomain = $pos->getActiveGtidCoordinates();
  98. if ( $thisPosByDomain && $thatPosByDomain ) {
  99. $comparisons = [];
  100. // Check that this has positions reaching those in $pos for all domains in common
  101. foreach ( $thatPosByDomain as $domain => $thatPos ) {
  102. if ( isset( $thisPosByDomain[$domain] ) ) {
  103. $comparisons[] = ( $thatPos <= $thisPosByDomain[$domain] );
  104. }
  105. }
  106. // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
  107. // quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot
  108. // be cleaned up. Assume that the domains in both this and $pos cover the relevant
  109. // active channels.
  110. return ( $comparisons && !in_array( false, $comparisons, true ) );
  111. }
  112. // Fallback to the binlog file comparisons
  113. $thisBinPos = $this->getBinlogCoordinates();
  114. $thatBinPos = $pos->getBinlogCoordinates();
  115. if ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] ) {
  116. return ( $thisBinPos['pos'] >= $thatBinPos['pos'] );
  117. }
  118. // Comparing totally different binlogs does not make sense
  119. return false;
  120. }
  121. public function channelsMatch( DBMasterPos $pos ) {
  122. if ( !( $pos instanceof self ) ) {
  123. throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
  124. }
  125. // Prefer GTID comparisons, which work with multi-tier replication
  126. $thisPosDomains = array_keys( $this->getActiveGtidCoordinates() );
  127. $thatPosDomains = array_keys( $pos->getActiveGtidCoordinates() );
  128. if ( $thisPosDomains && $thatPosDomains ) {
  129. // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
  130. // quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot
  131. // easily be cleaned up. Assume that the domains in both this and $pos cover the
  132. // relevant active channels.
  133. return array_intersect( $thatPosDomains, $thisPosDomains ) ? true : false;
  134. }
  135. // Fallback to the binlog file comparisons
  136. $thisBinPos = $this->getBinlogCoordinates();
  137. $thatBinPos = $pos->getBinlogCoordinates();
  138. return ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] );
  139. }
  140. /**
  141. * @return string|null Base name of binary log files
  142. * @since 1.31
  143. */
  144. public function getLogName() {
  145. return $this->gtids ? null : $this->binLog;
  146. }
  147. /**
  148. * @return int[]|null Tuple of (binary log file number, event number)
  149. * @since 1.31
  150. */
  151. public function getLogPosition() {
  152. return $this->gtids ? null : $this->logPos;
  153. }
  154. /**
  155. * @return string|null Name of the binary log file for this position
  156. * @since 1.31
  157. */
  158. public function getLogFile() {
  159. return $this->gtids ? null : "{$this->binLog}.{$this->logPos[self::CORD_INDEX]}";
  160. }
  161. /**
  162. * @return string[] Map of (server_uuid/gtid_domain_id => GTID)
  163. * @since 1.31
  164. */
  165. public function getGTIDs() {
  166. return $this->gtids;
  167. }
  168. /**
  169. * Set the GTID domain known to be used in new commits on a replication stream of interest
  170. *
  171. * This makes getRelevantActiveGTIDs() filter out GTIDs from other domains
  172. *
  173. * @see MySQLMasterPos::getRelevantActiveGTIDs()
  174. * @see https://mariadb.com/kb/en/library/gtid/#gtid_domain_id
  175. *
  176. * @param int|null $id @@gtid_domain_id of the active replication stream
  177. * @return MySQLMasterPos This instance (since 1.34)
  178. * @since 1.31
  179. */
  180. public function setActiveDomain( $id ) {
  181. $this->activeDomain = (int)$id;
  182. return $this;
  183. }
  184. /**
  185. * Set the server ID known to be used in new commits on a replication stream of interest
  186. *
  187. * This makes getRelevantActiveGTIDs() filter out GTIDs from other origin servers
  188. *
  189. * @see MySQLMasterPos::getRelevantActiveGTIDs()
  190. *
  191. * @param int|null $id @@server_id of the server were writes originate
  192. * @return MySQLMasterPos This instance (since 1.34)
  193. * @since 1.31
  194. */
  195. public function setActiveOriginServerId( $id ) {
  196. $this->activeServerId = (int)$id;
  197. return $this;
  198. }
  199. /**
  200. * Set the server UUID known to be used in new commits on a replication stream of interest
  201. *
  202. * This makes getRelevantActiveGTIDs() filter out GTIDs from other origin servers
  203. *
  204. * @see MySQLMasterPos::getRelevantActiveGTIDs()
  205. *
  206. * @param string|null $id @@server_uuid of the server were writes originate
  207. * @return MySQLMasterPos This instance (since 1.34)
  208. * @since 1.31
  209. */
  210. public function setActiveOriginServerUUID( $id ) {
  211. $this->activeServerUUID = $id;
  212. return $this;
  213. }
  214. /**
  215. * @param MySQLMasterPos $pos
  216. * @param MySQLMasterPos $refPos
  217. * @return string[] List of active GTIDs from $pos that have domains in $refPos
  218. * @since 1.34
  219. */
  220. public static function getRelevantActiveGTIDs( MySQLMasterPos $pos, MySQLMasterPos $refPos ) {
  221. return array_values( array_intersect_key(
  222. $pos->gtids,
  223. $pos->getActiveGtidCoordinates(),
  224. $refPos->gtids
  225. ) );
  226. }
  227. /**
  228. * @see https://mariadb.com/kb/en/mariadb/gtid
  229. * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
  230. * @return array Map of (server_uuid/gtid_domain_id => integer position); possibly empty
  231. */
  232. protected function getActiveGtidCoordinates() {
  233. $gtidInfos = [];
  234. foreach ( $this->gtids as $domain => $gtid ) {
  235. list( $domain, $pos, $server ) = self::parseGTID( $gtid );
  236. $ignore = false;
  237. // Filter out GTIDs from non-active replication domains
  238. if ( $this->style === self::GTID_MARIA && $this->activeDomain !== null ) {
  239. $ignore |= ( $domain !== $this->activeDomain );
  240. }
  241. // Likewise for GTIDs from non-active replication origin servers
  242. if ( $this->style === self::GTID_MARIA && $this->activeServerId !== null ) {
  243. $ignore |= ( $server !== $this->activeServerId );
  244. } elseif ( $this->style === self::GTID_MYSQL && $this->activeServerUUID !== null ) {
  245. $ignore |= ( $server !== $this->activeServerUUID );
  246. }
  247. if ( !$ignore ) {
  248. $gtidInfos[$domain] = $pos;
  249. }
  250. }
  251. return $gtidInfos;
  252. }
  253. /**
  254. * @param string $id GTID
  255. * @return array|null [domain ID or server UUID, sequence number, server ID/UUID] or null
  256. */
  257. protected static function parseGTID( $id ) {
  258. $m = [];
  259. if ( preg_match( '!^(\d+)-(\d+)-(\d+)$!', $id, $m ) ) {
  260. // MariaDB style: <domain>-<server id>-<sequence number>
  261. return [ (int)$m[1], (int)$m[3], (int)$m[2] ];
  262. } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(?:\d+-|)(\d+)$!', $id, $m ) ) {
  263. // MySQL style: <server UUID>:<sequence number>-<sequence number>
  264. // Normally, the first number should reflect the point (gtid_purged) where older
  265. // binary logs where purged to save space. When doing comparisons, it may as well
  266. // be 1 in that case. Assume that this is generally the situation.
  267. return [ $m[1], (int)$m[2], $m[1] ];
  268. }
  269. return null;
  270. }
  271. /**
  272. * @see https://dev.mysql.com/doc/refman/5.7/en/show-master-status.html
  273. * @see https://dev.mysql.com/doc/refman/5.7/en/show-slave-status.html
  274. * @return array|bool Map of (binlog:<string>, pos:(<integer>, <integer>)) or false
  275. */
  276. protected function getBinlogCoordinates() {
  277. return ( $this->binLog !== null && $this->logPos !== null )
  278. ? [ 'binlog' => $this->binLog, 'pos' => $this->logPos ]
  279. : false;
  280. }
  281. public function serialize() {
  282. return serialize( [
  283. 'position' => $this->__toString(),
  284. 'activeDomain' => $this->activeDomain,
  285. 'activeServerId' => $this->activeServerId,
  286. 'activeServerUUID' => $this->activeServerUUID,
  287. 'asOfTime' => $this->asOfTime
  288. ] );
  289. }
  290. public function unserialize( $serialized ) {
  291. $data = unserialize( $serialized );
  292. if ( !is_array( $data ) ) {
  293. throw new UnexpectedValueException( __METHOD__ . ": cannot unserialize position" );
  294. }
  295. $this->init( $data['position'], $data['asOfTime'] );
  296. if ( isset( $data['activeDomain'] ) ) {
  297. $this->setActiveDomain( $data['activeDomain'] );
  298. }
  299. if ( isset( $data['activeServerId'] ) ) {
  300. $this->setActiveOriginServerId( $data['activeServerId'] );
  301. }
  302. if ( isset( $data['activeServerUUID'] ) ) {
  303. $this->setActiveOriginServerUUID( $data['activeServerUUID'] );
  304. }
  305. }
  306. /**
  307. * @return string GTID set or <binary log file>/<position> (e.g db1034-bin.000976/843431247)
  308. */
  309. public function __toString() {
  310. return $this->gtids
  311. ? implode( ',', $this->gtids )
  312. : $this->getLogFile() . "/{$this->logPos[self::CORD_EVENT]}";
  313. }
  314. }