Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.00% covered (success)
90.00%
90 / 100
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
MySQLPrimaryPos
90.00% covered (success)
90.00%
90 / 100
70.59% covered (warning)
70.59%
12 / 17
56.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 asOfTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasReached
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
10.03
 getLogPosition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getLogFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getGTIDs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setActiveDomain
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setActiveOriginServerId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setActiveOriginServerUUID
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRelevantActiveGTIDs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getActiveGtidCoordinates
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
12.52
 parseGTID
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 getBinlogCoordinates
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 newFromArray
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 toArray
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Wikimedia\Rdbms;
4
5use InvalidArgumentException;
6use Stringable;
7
8/**
9 * DBPrimaryPos implementation for MySQL and MariaDB.
10 *
11 * Note that primary positions and sync logic here make some assumptions:
12 *
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).
16 *
17 * @see https://mariadb.com/kb/en/library/gtid/
18 * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
19 * @internal
20 */
21class MySQLPrimaryPos implements Stringable, DBPrimaryPos {
22    /** @var string One of (BINARY_LOG, GTID_MYSQL, GTID_MARIA) */
23    private $style;
24    /** @var string|null Base name of all Binary Log files */
25    private $binLog;
26    /** @var array<int,int|string>|null Binary Log position tuple (index number, event number) */
27    private $logPos;
28    /** @var string[] Map of (server_uuid/gtid_domain_id => GTID) */
29    private $gtids = [];
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;
38
39    private const BINARY_LOG = 'binary-log';
40    private const GTID_MARIA = 'gtid-maria';
41    private const GTID_MYSQL = 'gtid-mysql';
42
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;
47
48    /**
49     * @param string $position One of (comma separated GTID list, <binlog file>/<64 bit integer>)
50     * @param float $asOfTime UNIX timestamp
51     */
52    public function __construct( $position, $asOfTime ) {
53        $this->init( $position, $asOfTime );
54    }
55
56    /**
57     * @param string $position
58     * @param float $asOfTime
59     */
60    protected function init( $position, $asOfTime ) {
61        $m = [];
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;
66        } else {
67            $gtids = array_filter( array_map( 'trim', explode( ',', $position ) ) );
68            foreach ( $gtids as $gtid ) {
69                $components = self::parseGTID( $gtid );
70                if ( !$components ) {
71                    throw new InvalidArgumentException( "Invalid GTID '$gtid'." );
72                }
73
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;
82                    }
83                } else {
84                    $this->gtids[$domain] = $gtid;
85                }
86
87                if ( is_string( $domain ) ) {
88                    $this->style = self::GTID_MARIA; // gtid_domain_id
89                } else {
90                    $this->style = self::GTID_MYSQL; // server_uuid
91                }
92            }
93            if ( !$this->gtids ) {
94                throw new InvalidArgumentException( "GTID set cannot be empty." );
95            }
96        }
97
98        $this->asOfTime = $asOfTime;
99    }
100
101    public function asOfTime() {
102        return $this->asOfTime;
103    }
104
105    public function hasReached( DBPrimaryPos $pos ) {
106        if ( !( $pos instanceof self ) ) {
107            throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
108        }
109
110        // Prefer GTID comparisons, which work with multi-tier replication
111        $thisPosByDomain = $this->getActiveGtidCoordinates();
112        $thatPosByDomain = $pos->getActiveGtidCoordinates();
113        if ( $thisPosByDomain && $thatPosByDomain ) {
114            $comparisons = [];
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] );
119                }
120            }
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
124            // active channels.
125            return ( $comparisons && !in_array( false, $comparisons, true ) );
126        }
127
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'] );
133        }
134
135        // Comparing totally different binlogs does not make sense
136        return false;
137    }
138
139    /**
140     * @return array<int,int|string>|null Tuple of (binary log file number, 64 bit event number)
141     * @since 1.31
142     */
143    public function getLogPosition() {
144        return $this->gtids ? null : $this->logPos;
145    }
146
147    /**
148     * @return string|null Name of the binary log file for this position
149     * @since 1.31
150     */
151    public function getLogFile() {
152        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
153        return $this->gtids ? null : "{$this->binLog}.{$this->logPos[self::CORD_INDEX]}";
154    }
155
156    /**
157     * @return array<string,string> Map of (server_uuid/gtid_domain_id => GTID)
158     * @since 1.31
159     */
160    public function getGTIDs() {
161        return $this->gtids;
162    }
163
164    /**
165     * Set the GTID domain known to be used in new commits on a replication stream of interest
166     *
167     * This makes getRelevantActiveGTIDs() filter out GTIDs from other domains
168     *
169     * @see MySQLPrimaryPos::getRelevantActiveGTIDs()
170     * @see https://mariadb.com/kb/en/library/gtid/#gtid_domain_id
171     *
172     * @param string|int|null $id @@gtid_domain_id of the active replication stream
173     * @return MySQLPrimaryPos This instance (since 1.34)
174     * @since 1.31
175     */
176    public function setActiveDomain( $id ) {
177        $this->activeDomain = (string)$id;
178
179        return $this;
180    }
181
182    /**
183     * Set the server ID known to be used in new commits on a replication stream of interest
184     *
185     * This makes getRelevantActiveGTIDs() filter out GTIDs from other origin servers
186     *
187     * @see MySQLPrimaryPos::getRelevantActiveGTIDs()
188     *
189     * @param string|int|null $id @@server_id of the server were writes originate
190     * @return MySQLPrimaryPos This instance (since 1.34)
191     * @since 1.31
192     */
193    public function setActiveOriginServerId( $id ) {
194        $this->activeServerId = (string)$id;
195
196        return $this;
197    }
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 MySQLPrimaryPos::getRelevantActiveGTIDs()
205     *
206     * @param string|null $id @@server_uuid of the server were writes originate
207     * @return MySQLPrimaryPos This instance (since 1.34)
208     * @since 1.31
209     */
210    public function setActiveOriginServerUUID( $id ) {
211        $this->activeServerUUID = $id;
212
213        return $this;
214    }
215
216    /**
217     * @param MySQLPrimaryPos $pos
218     * @param MySQLPrimaryPos $refPos
219     * @return string[] List of active GTIDs from $pos that have domains in $refPos
220     * @since 1.34
221     */
222    public static function getRelevantActiveGTIDs( MySQLPrimaryPos $pos, MySQLPrimaryPos $refPos ) {
223        return array_values( array_intersect_key(
224            $pos->gtids,
225            $pos->getActiveGtidCoordinates(),
226            $refPos->gtids
227        ) );
228    }
229
230    /**
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)
234     */
235    protected function getActiveGtidCoordinates() {
236        $gtidInfos = [];
237
238        foreach ( $this->gtids as $gtid ) {
239            [ $domain, $pos, $server ] = self::parseGTID( $gtid );
240
241            $ignore = false;
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 );
245            }
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 );
251            }
252
253            if ( !$ignore ) {
254                $gtidInfos[$domain] = $pos;
255            }
256        }
257
258        return $gtidInfos;
259    }
260
261    /**
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
265     */
266    protected static function parseGTID( $id ) {
267        $m = [];
268        if ( preg_match( '!^(\d+)-(\d+)-(\d+)$!', $id, $m ) ) {
269            // MariaDB style: "<32 bit domain ID>-<32 bit server id>-<64 bit event number>"
270            $channelId = $m[1];
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.
278            $channelId = $m[1];
279            $originServerId = $m[1];
280            $eventNumber = $m[2];
281        } else {
282            return null;
283        }
284
285        return [ $channelId, $eventNumber, $originServerId ];
286    }
287
288    /**
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
292     */
293    protected function getBinlogCoordinates() {
294        return ( $this->binLog !== null && $this->logPos !== null )
295            ? [ 'binlog' => $this->binLog, 'pos' => $this->logPos ]
296            : false;
297    }
298
299    public static function newFromArray( array $data ) {
300        $pos = new self( $data['position'], $data['asOfTime'] );
301
302        if ( isset( $data['activeDomain'] ) ) {
303            $pos->setActiveDomain( $data['activeDomain'] );
304        }
305        if ( isset( $data['activeServerId'] ) ) {
306            $pos->setActiveOriginServerId( $data['activeServerId'] );
307        }
308        if ( isset( $data['activeServerUUID'] ) ) {
309            $pos->setActiveOriginServerUUID( $data['activeServerUUID'] );
310        }
311        return $pos;
312    }
313
314    public function toArray(): array {
315        return [
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
322        ];
323    }
324
325    /**
326     * @return string GTID set or <binary log file>/<position> (e.g db1034-bin.000976/843431247)
327     */
328    public function __toString() {
329        return $this->gtids
330            ? implode( ',', $this->gtids )
331            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
332            : $this->getLogFile() . "/{$this->logPos[self::CORD_EVENT]}";
333    }
334
335}