@powersync/service-module-mysql 0.14.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/api/MySQLRouteAPIAdapter.d.ts +2 -2
  3. package/dist/api/MySQLRouteAPIAdapter.js +6 -5
  4. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/ReplicatedGTID.d.ts +16 -9
  6. package/dist/common/ReplicatedGTID.js +49 -25
  7. package/dist/common/ReplicatedGTID.js.map +1 -1
  8. package/dist/common/check-source-configuration.js +11 -0
  9. package/dist/common/check-source-configuration.js.map +1 -1
  10. package/dist/common/read-executed-gtid.d.ts +11 -1
  11. package/dist/common/read-executed-gtid.js +67 -5
  12. package/dist/common/read-executed-gtid.js.map +1 -1
  13. package/dist/common/schema-utils.js +4 -0
  14. package/dist/common/schema-utils.js.map +1 -1
  15. package/dist/replication/BinLogStream.d.ts +2 -0
  16. package/dist/replication/BinLogStream.js +42 -14
  17. package/dist/replication/BinLogStream.js.map +1 -1
  18. package/dist/replication/zongji/BinLogListener.d.ts +17 -2
  19. package/dist/replication/zongji/BinLogListener.js +42 -29
  20. package/dist/replication/zongji/BinLogListener.js.map +1 -1
  21. package/package.json +8 -8
  22. package/src/api/MySQLRouteAPIAdapter.ts +9 -8
  23. package/src/common/ReplicatedGTID.ts +65 -32
  24. package/src/common/check-source-configuration.ts +15 -0
  25. package/src/common/read-executed-gtid.ts +76 -6
  26. package/src/common/schema-utils.ts +8 -0
  27. package/src/replication/BinLogStream.ts +45 -14
  28. package/src/replication/zongji/BinLogListener.ts +61 -29
  29. package/test/src/BinLogListener.test.ts +18 -0
  30. package/test/src/BinlogStreamUtils.ts +2 -2
  31. package/test/src/ReplicatedGTID.test.ts +138 -0
  32. package/test/src/check-source-configuration.test.ts +70 -0
  33. package/test/src/read-executed-gtid.test.ts +188 -0
  34. package/test/src/util.ts +25 -2
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -1,3 +1,4 @@
1
+ import { ReplicationAssertionError } from '@powersync/lib-services-framework';
1
2
  import mysql from 'mysql2/promise';
2
3
  import * as uuid from 'uuid';
3
4
  import * as mysql_utils from '../utils/mysql-utils.js';
@@ -8,7 +9,11 @@ export type BinLogPosition = {
8
9
  };
9
10
 
10
11
  export type ReplicatedGTIDSpecification = {
11
- raw_gtid: string;
12
+ /**
13
+ * The raw Global Transaction ID. This is of the format `server_uuid:transaction_id`.
14
+ * Must be a single GTID — not a GTID set (multiple UUIDs) or interval range.
15
+ */
16
+ rawGtid: string;
12
17
  /**
13
18
  * The (end) position in a BinLog file where this transaction has been replicated in.
14
19
  */
@@ -16,12 +21,12 @@ export type ReplicatedGTIDSpecification = {
16
21
  };
17
22
 
18
23
  export type BinLogGTIDFormat = {
19
- server_id: Buffer;
20
- transaction_range: number;
24
+ serverUuid: Buffer;
25
+ transactionId: number;
21
26
  };
22
27
 
23
28
  export type BinLogGTIDEvent = {
24
- raw_gtid: BinLogGTIDFormat;
29
+ rawGtid: BinLogGTIDFormat;
25
30
  position: BinLogPosition;
26
31
  };
27
32
 
@@ -31,30 +36,43 @@ export type BinLogGTIDEvent = {
31
36
  * and position where this GTID could be located.
32
37
  */
33
38
  export class ReplicatedGTID {
39
+ private options: ReplicatedGTIDSpecification;
40
+
41
+ constructor(options: ReplicatedGTIDSpecification) {
42
+ const rawGtid = options.rawGtid.trim();
43
+ assertSingleGtid(rawGtid);
44
+ this.options = { ...options, rawGtid };
45
+ }
46
+
34
47
  static fromSerialized(comparable: string): ReplicatedGTID {
35
48
  return new ReplicatedGTID(ReplicatedGTID.deserialize(comparable));
36
49
  }
37
50
 
38
51
  private static deserialize(comparable: string): ReplicatedGTIDSpecification {
39
52
  const components = comparable.split('|');
40
- if (components.length < 3) {
41
- throw new Error(`Invalid serialized GTID: ${comparable}`);
53
+ if (components.length < 4) {
54
+ throw new ReplicationAssertionError(`Invalid serialized GTID: ${comparable}`);
55
+ }
56
+
57
+ const offset = parseInt(components[3], 10);
58
+ if (Number.isNaN(offset)) {
59
+ throw new ReplicationAssertionError(`Invalid BinLog offset in serialized GTID: ${comparable}`);
42
60
  }
43
61
 
44
62
  return {
45
- raw_gtid: components[1],
63
+ rawGtid: components[1],
46
64
  position: {
47
65
  filename: components[2],
48
- offset: parseInt(components[3])
66
+ offset: offset
49
67
  } satisfies BinLogPosition
50
68
  };
51
69
  }
52
70
 
53
71
  static fromBinLogEvent(event: BinLogGTIDEvent) {
54
- const { raw_gtid, position } = event;
55
- const stringGTID = `${uuid.stringify(raw_gtid.server_id)}:${raw_gtid.transaction_range}`;
72
+ const { rawGtid, position } = event;
73
+ const stringGTID = `${uuid.stringify(rawGtid.serverUuid)}:${rawGtid.transactionId}`;
56
74
  return new ReplicatedGTID({
57
- raw_gtid: stringGTID,
75
+ rawGtid: stringGTID,
58
76
  position
59
77
  });
60
78
  }
@@ -62,9 +80,12 @@ export class ReplicatedGTID {
62
80
  /**
63
81
  * Special case for the zero GTID which means no transactions have been executed.
64
82
  */
65
- static ZERO = new ReplicatedGTID({ raw_gtid: '0:0', position: { filename: '', offset: 0 } });
66
-
67
- constructor(protected options: ReplicatedGTIDSpecification) {}
83
+ static ZERO(serverUuid: string): ReplicatedGTID {
84
+ return new ReplicatedGTID({
85
+ rawGtid: `${serverUuid}:0`,
86
+ position: { filename: '', offset: 0 }
87
+ });
88
+ }
68
89
 
69
90
  /**
70
91
  * Get the BinLog position of this replicated GTID event
@@ -74,14 +95,17 @@ export class ReplicatedGTID {
74
95
  }
75
96
 
76
97
  /**
77
- * Get the raw Global Transaction ID. This of the format `server_id:transaction_ranges`
98
+ * Get the raw Global Transaction ID. This is of the format `server_uuid:transaction_id`
78
99
  */
79
100
  get raw() {
80
- return this.options.raw_gtid;
101
+ return this.options.rawGtid;
81
102
  }
82
103
 
83
- get serverId() {
84
- return this.options.raw_gtid.split(':')[0];
104
+ /**
105
+ * The server UUID of the server this transaction originated from
106
+ */
107
+ get serverUuid() {
108
+ return this.options.rawGtid.split(':')[0];
85
109
  }
86
110
 
87
111
  /**
@@ -94,21 +118,9 @@ export class ReplicatedGTID {
94
118
  */
95
119
  get comparable(): string {
96
120
  const { raw, position } = this;
97
- const [, transactionRanges] = this.raw.split(':');
121
+ const [, transactionId] = this.raw.split(':');
98
122
 
99
- // This means no transactions have been executed on the database yet
100
- if (!transactionRanges) {
101
- return ReplicatedGTID.ZERO.comparable;
102
- }
103
-
104
- let maxTransactionId = 0;
105
-
106
- for (const range of transactionRanges.split(',')) {
107
- const [start, end] = range.split('-');
108
- maxTransactionId = Math.max(maxTransactionId, parseInt(start, 10), parseInt(end || start, 10));
109
- }
110
-
111
- const paddedTransactionId = maxTransactionId.toString().padStart(16, '0');
123
+ const paddedTransactionId = transactionId.toString().padStart(16, '0');
112
124
  return [paddedTransactionId, raw, position.filename, position.offset].join('|');
113
125
  }
114
126
 
@@ -161,3 +173,24 @@ export class ReplicatedGTID {
161
173
  );
162
174
  }
163
175
  }
176
+
177
+ /**
178
+ * Asserts that the given gtid string is a single GTID of the form `server_uuid:transaction_id`,
179
+ * not a GTID set such as `uuid:1-17` or `uuid1:1,uuid2:2`.
180
+ */
181
+ function assertSingleGtid(gtid: string): void {
182
+ // GTID sets join UUID sets with commas (often with newlines: `,\n`).
183
+ if (gtid.includes(',') || gtid.includes('\n')) {
184
+ throw new ReplicationAssertionError(`Expected a single GTID (server_uuid:transaction_id), got a GTID set: ${gtid}`);
185
+ }
186
+
187
+ const parts = gtid.split(':');
188
+ if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) {
189
+ throw new ReplicationAssertionError(`Expected a single GTID (server_uuid:transaction_id), got: ${gtid}`);
190
+ }
191
+
192
+ // Intervals use `n-m`; a single transaction id must be a non-negative integer.
193
+ if (!/^\d+$/.test(parts[1])) {
194
+ throw new ReplicationAssertionError(`Expected a single transaction id, got: ${gtid}`);
195
+ }
196
+ }
@@ -2,6 +2,7 @@ import mysqlPromise from 'mysql2/promise';
2
2
  import * as mysql_utils from '../utils/mysql-utils.js';
3
3
 
4
4
  const MIN_SUPPORTED_VERSION = '5.7.0';
5
+ const REPLICA_TERMINOLOGY_VERSION = '8.0.22';
5
6
 
6
7
  export async function checkSourceConfiguration(connection: mysqlPromise.Connection): Promise<string[]> {
7
8
  const errors: string[] = [];
@@ -11,6 +12,20 @@ export async function checkSourceConfiguration(connection: mysqlPromise.Connecti
11
12
  errors.push(`MySQL versions older than ${MIN_SUPPORTED_VERSION} are not supported. Your version is: ${version}.`);
12
13
  }
13
14
 
15
+ const replicaStatusQuery = mysql_utils.isVersionAtLeast(version, REPLICA_TERMINOLOGY_VERSION)
16
+ ? 'SHOW REPLICA STATUS'
17
+ : 'SHOW SLAVE STATUS';
18
+ const [replicaStatuses] = await mysql_utils.retriedQuery({
19
+ connection,
20
+ query: replicaStatusQuery
21
+ });
22
+
23
+ if (replicaStatuses.length > 0) {
24
+ errors.push(
25
+ 'Connecting PowerSync to a MySQL replica is not supported. Please connect PowerSync directly to the primary server.'
26
+ );
27
+ }
28
+
14
29
  const [[result]] = await mysql_utils.retriedQuery({
15
30
  connection,
16
31
  query: `
@@ -2,6 +2,17 @@ import mysqlPromise from 'mysql2/promise';
2
2
  import * as mysql_utils from '../utils/mysql-utils.js';
3
3
  import { ReplicatedGTID } from './ReplicatedGTID.js';
4
4
 
5
+ /**
6
+ * Gets the `@@server_uuid` of the current connected server
7
+ */
8
+ export async function readServerUuid(connection: mysqlPromise.Connection): Promise<string> {
9
+ const [[result]] = await mysql_utils.retriedQuery({
10
+ connection,
11
+ query: `SELECT @@server_uuid AS server_uuid`
12
+ });
13
+ return result.server_uuid;
14
+ }
15
+
5
16
  /**
6
17
  * Gets the current master HEAD GTID
7
18
  */
@@ -28,21 +39,80 @@ export async function readExecutedGtid(connection: mysqlPromise.Connection): Pro
28
39
  offset: parseInt(binlogStatus.Position)
29
40
  };
30
41
 
42
+ const activeServerUuid = await readServerUuid(connection);
43
+ const executedGtidSet = binlogStatus.Executed_Gtid_Set.trim();
44
+
45
+ if (executedGtidSet.length === 0) {
46
+ // New server with no transactions executed yet. Keep the current binlog
47
+ // coordinate so this synthetic GTID can still be validated after a restart.
48
+ return new ReplicatedGTID({
49
+ rawGtid: `${activeServerUuid}:0`,
50
+ position
51
+ });
52
+ }
53
+
54
+ const gtidSets = executedGtidSet.split(',');
55
+ const latestActiveGtid = await getLatestActiveGtid(gtidSets, activeServerUuid);
31
56
  return new ReplicatedGTID({
32
- // The head always points to the next position to start replication from
33
- position,
34
- raw_gtid: binlogStatus.Executed_Gtid_Set
57
+ rawGtid: latestActiveGtid,
58
+ position
35
59
  });
36
60
  }
37
61
 
38
- export async function isBinlogStillAvailable(
62
+ export async function getLatestActiveGtid(gtidSets: string[], activeServerUuid: string): Promise<string> {
63
+ for (const gtidSet of gtidSets) {
64
+ const [serverUuid, ...intervals] = gtidSet.trim().split(':');
65
+ if (serverUuid === activeServerUuid) {
66
+ let maxTransactionId: number | null = null;
67
+ for (const interval of intervals) {
68
+ const [start, end] = interval.split('-');
69
+ const startId = parseInt(start, 10);
70
+ const endId = end !== undefined ? parseInt(end, 10) : startId;
71
+ if (!Number.isNaN(startId)) {
72
+ maxTransactionId = Math.max(maxTransactionId ?? 0, startId);
73
+ }
74
+ if (!Number.isNaN(endId)) {
75
+ maxTransactionId = Math.max(maxTransactionId ?? 0, endId);
76
+ }
77
+ }
78
+ return activeServerUuid + ':' + maxTransactionId;
79
+ }
80
+ }
81
+
82
+ return `${activeServerUuid}:0`;
83
+ }
84
+
85
+ /**
86
+ * Checks that a stored resume GTID is still part of the server's executed history and that its
87
+ * binlog coordinate is still readable. This detects source rewinds where a restored server keeps
88
+ * the same UUID or recreates a binlog with the same filename but a shorter length.
89
+ */
90
+ export async function isGtidPositionStillAvailable(
39
91
  connection: mysqlPromise.Connection,
40
- binlogFile: string
92
+ gtid: ReplicatedGTID
41
93
  ): Promise<boolean> {
42
94
  const [logFiles] = await mysql_utils.retriedQuery({
43
95
  connection,
44
96
  query: `SHOW BINARY LOGS;`
45
97
  });
98
+ const logFile = logFiles.find((file) => file['Log_name'] == gtid.position.filename);
99
+
100
+ if (!logFile || Number(logFile['File_size']) < gtid.position.offset) {
101
+ return false;
102
+ }
103
+
104
+ // Transaction zero is PowerSync's synthetic position before the first
105
+ // transaction from this server UUID. It is not valid MySQL GTID_SET syntax,
106
+ // so its availability is determined by the binlog coordinate above.
107
+ if (gtid.raw.split(':')[1] === '0') {
108
+ return true;
109
+ }
110
+
111
+ const [[result]] = await mysql_utils.retriedQuery({
112
+ connection,
113
+ query: `SELECT GTID_SUBSET(?, @@GLOBAL.gtid_executed) AS is_executed`,
114
+ params: [gtid.raw]
115
+ });
46
116
 
47
- return logFiles.some((f) => f['Log_name'] == binlogFile);
117
+ return result.is_executed === 1;
48
118
  }
@@ -1,3 +1,4 @@
1
+ import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
1
2
  import { ColumnDescriptor } from '@powersync/service-core';
2
3
  import { TablePattern } from '@powersync/service-sync-rules';
3
4
  import mysqlPromise from 'mysql2/promise';
@@ -147,6 +148,13 @@ export async function getTablesFromPattern(
147
148
  connection: mysqlPromise.Connection,
148
149
  tablePattern: TablePattern
149
150
  ): Promise<string[]> {
151
+ if (tablePattern.isSchemaWildcard) {
152
+ throw new ServiceError(
153
+ ErrorCode.PSYNC_R2201,
154
+ 'Schema wildcards ("%") in table patterns are not supported for MySQL connections.'
155
+ );
156
+ }
157
+
150
158
  const schema = tablePattern.schema;
151
159
 
152
160
  if (tablePattern.isWildcard) {
@@ -73,6 +73,8 @@ export class BinLogStream {
73
73
 
74
74
  private readonly logger: Logger;
75
75
 
76
+ private activeServerUuid: string | null = null;
77
+
76
78
  private tableCache = new Map<string | number, storage.SourceTable[]>();
77
79
 
78
80
  private replicationLag = new ReplicationLagTracker();
@@ -220,16 +222,23 @@ export class BinLogStream {
220
222
  this.logger.info(`Initial replication already done.`);
221
223
 
222
224
  if (lastKnowGTID) {
223
- // Check if the specific binlog file is still available. If it isn't, we need to snapshot again.
224
225
  const connection = await this.connections.getConnection();
225
226
  try {
226
- const isAvailable = await common.isBinlogStillAvailable(connection, lastKnowGTID.position.filename);
227
+ // Check if the active server uuid matches the one in the GTID
228
+ if (this.activeServerUuid !== lastKnowGTID.serverUuid) {
229
+ this.logger.info(
230
+ `The source server uuid has changed. Active server uuid ${this.activeServerUuid} does not match the server uuid from the resume checkpoint: ${lastKnowGTID.serverUuid}, re-snapshotting to ensure consistency.`
231
+ );
232
+ return false;
233
+ }
234
+
235
+ const isAvailable = await common.isGtidPositionStillAvailable(connection, lastKnowGTID);
227
236
  if (!isAvailable) {
228
237
  this.logger.info(
229
- `BinLog file ${lastKnowGTID.position.filename} is no longer available, starting initial replication again.`
238
+ `Resume GTID ${lastKnowGTID.raw} at ${lastKnowGTID.position.filename}:${lastKnowGTID.position.offset} is no longer present in the executed GTID history or available BinLogs, re-snapshotting to ensure consistency.`
230
239
  );
240
+ return false;
231
241
  }
232
- return isAvailable;
233
242
  } finally {
234
243
  connection.release();
235
244
  }
@@ -267,7 +276,7 @@ export class BinLogStream {
267
276
  const flushResults = await this.storage.startBatch(
268
277
  {
269
278
  logger: this.logger,
270
- zeroLSN: common.ReplicatedGTID.ZERO.comparable,
279
+ zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
271
280
  defaultSchema: this.defaultSchema,
272
281
  storeCurrentData: false
273
282
  },
@@ -363,7 +372,19 @@ export class BinLogStream {
363
372
  }
364
373
  }
365
374
 
375
+ private async ensureActiveServerUuid() {
376
+ if (this.activeServerUuid == null) {
377
+ const connection = await this.connections.getConnection();
378
+ try {
379
+ this.activeServerUuid = await common.readServerUuid(connection);
380
+ } finally {
381
+ connection.release();
382
+ }
383
+ }
384
+ }
385
+
366
386
  async initReplication() {
387
+ await this.ensureActiveServerUuid();
367
388
  const connection = await this.connections.getConnection();
368
389
  const errors = await common.checkSourceConfiguration(connection);
369
390
  connection.release();
@@ -382,7 +403,7 @@ export class BinLogStream {
382
403
  await this.storage.startBatch(
383
404
  {
384
405
  logger: this.logger,
385
- zeroLSN: common.ReplicatedGTID.ZERO.comparable,
406
+ zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
386
407
  defaultSchema: this.defaultSchema,
387
408
  storeCurrentData: false
388
409
  },
@@ -406,21 +427,30 @@ export class BinLogStream {
406
427
  }
407
428
 
408
429
  async streamChanges() {
430
+ await this.ensureActiveServerUuid();
409
431
  const serverId = createRandomServerId(this.storage.replicationStreamId);
410
432
 
411
433
  const connection = await this.connections.getConnection();
412
- const { resumeLsn: resume_lsn } = await this.storage.getStatus();
413
- if (resume_lsn) {
414
- this.logger.info(`Existing resume LSN found: ${resume_lsn}`);
434
+ let fromGTID: common.ReplicatedGTID;
435
+ try {
436
+ const { resumeLsn: resume_lsn } = await this.storage.getStatus();
437
+ if (resume_lsn) {
438
+ this.logger.info(`Existing resume LSN found: ${resume_lsn}`);
439
+ }
440
+ fromGTID = resume_lsn
441
+ ? common.ReplicatedGTID.fromSerialized(resume_lsn)
442
+ : await common.readExecutedGtid(connection);
443
+ } finally {
444
+ connection.release();
415
445
  }
416
- const fromGTID = resume_lsn
417
- ? common.ReplicatedGTID.fromSerialized(resume_lsn)
418
- : await common.readExecutedGtid(connection);
419
- connection.release();
420
446
 
421
447
  if (!this.stopped) {
422
448
  await this.storage.startBatch(
423
- { zeroLSN: common.ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: false },
449
+ {
450
+ zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
451
+ defaultSchema: this.defaultSchema,
452
+ storeCurrentData: false
453
+ },
424
454
  async (batch) => {
425
455
  const binlogEventHandler = this.createBinlogEventHandler(batch);
426
456
  const binlogListener = new BinLogListener({
@@ -429,6 +459,7 @@ export class BinLogStream {
429
459
  startGTID: fromGTID,
430
460
  connectionManager: this.connections,
431
461
  serverId: serverId,
462
+ activeServerUuid: this.activeServerUuid!,
432
463
  eventHandler: binlogEventHandler
433
464
  });
434
465
 
@@ -1,4 +1,4 @@
1
- import { Logger, logger as defaultLogger } from '@powersync/lib-services-framework';
1
+ import { Logger, ReplicationAssertionError, logger as defaultLogger } from '@powersync/lib-services-framework';
2
2
  import { BinLogEvent, BinLogQueryEvent, StartOptions, TableMapEntry, ZongJi } from '@powersync/mysql-zongji';
3
3
  import { TablePattern } from '@powersync/service-sync-rules';
4
4
  import async from 'async';
@@ -76,7 +76,14 @@ export interface BinLogListenerOptions {
76
76
  connectionManager: MySQLConnectionManager;
77
77
  eventHandler: BinLogEventHandler;
78
78
  sourceTables: TablePattern[];
79
+ /**
80
+ * Id that identifies this replication client.
81
+ */
79
82
  serverId: number;
83
+ /**
84
+ * The server uuid of the source MySQL server that is being replicated.
85
+ */
86
+ activeServerUuid: string;
80
87
  startGTID: common.ReplicatedGTID;
81
88
  logger?: Logger;
82
89
  keepAliveInactivitySeconds?: number;
@@ -88,8 +95,6 @@ export interface BinLogListenerOptions {
88
95
  */
89
96
  export class BinLogListener {
90
97
  private sqlParser: ParserType;
91
- private connectionManager: MySQLConnectionManager;
92
- private eventHandler: BinLogEventHandler;
93
98
  private binLogPosition: common.BinLogPosition;
94
99
  private currentGTID: common.ReplicatedGTID;
95
100
  private logger: Logger;
@@ -101,6 +106,7 @@ export class BinLogListener {
101
106
 
102
107
  // Flag to indicate if are currently in a transaction that involves multiple row mutation events.
103
108
  private isTransactionOpen = false;
109
+
104
110
  zongji: ZongJi;
105
111
  processingQueue: async.QueueObject<BinLogEvent>;
106
112
 
@@ -111,9 +117,8 @@ export class BinLogListener {
111
117
 
112
118
  constructor(public options: BinLogListenerOptions) {
113
119
  this.logger = options.logger ?? defaultLogger;
114
- this.connectionManager = options.connectionManager;
115
- this.eventHandler = options.eventHandler;
116
- this.binLogPosition = options.startGTID.position;
120
+ // Copy the position: the listener mutates it as events are processed, and the caller's startGTID must not change
121
+ this.binLogPosition = { ...options.startGTID.position };
117
122
  this.currentGTID = options.startGTID;
118
123
  this.sqlParser = new Parser();
119
124
  this.processingQueue = this.createProcessingQueue();
@@ -122,6 +127,18 @@ export class BinLogListener {
122
127
  this.databaseFilter = this.createDatabaseFilter(options.sourceTables);
123
128
  }
124
129
 
130
+ private get connectionManager(): MySQLConnectionManager {
131
+ return this.options.connectionManager;
132
+ }
133
+
134
+ private get eventHandler(): BinLogEventHandler {
135
+ return this.options.eventHandler;
136
+ }
137
+
138
+ private get activeServerUuid(): string {
139
+ return this.options.activeServerUuid;
140
+ }
141
+
125
142
  /**
126
143
  * The queue memory limit in bytes as defined in the connection options.
127
144
  * @private
@@ -292,29 +309,41 @@ export class BinLogListener {
292
309
  return async (evt: BinLogEvent) => {
293
310
  switch (true) {
294
311
  case zongji_utils.eventIsGTIDLog(evt):
295
- this.currentGTID = common.ReplicatedGTID.fromBinLogEvent({
296
- raw_gtid: {
297
- server_id: evt.serverId,
298
- transaction_range: evt.transactionRange
312
+ const transactionGTID = common.ReplicatedGTID.fromBinLogEvent({
313
+ rawGtid: {
314
+ serverUuid: evt.serverId, // The server uuid this transaction originated from
315
+ transactionId: evt.transactionRange
299
316
  },
300
317
  position: {
301
318
  filename: this.binLogPosition.filename,
302
319
  offset: evt.nextPosition
303
320
  }
304
321
  });
322
+
323
+ if (transactionGTID.serverUuid !== this.activeServerUuid) {
324
+ throw new ReplicationAssertionError(
325
+ `Detected a transaction from a different MySQL server UUID: ${transactionGTID.serverUuid} than the server that is currently being replicated from: ${this.activeServerUuid}. ` +
326
+ `A re-snapshot is required to ensure consistency.`
327
+ );
328
+ }
329
+
330
+ this.currentGTID = transactionGTID;
305
331
  this.binLogPosition.offset = evt.nextPosition;
332
+
306
333
  await this.eventHandler.onTransactionStart({ timestamp: new Date(evt.timestamp) });
307
334
  this.logger.info(`Processed GTID event: ${this.currentGTID.comparable}`);
308
335
  break;
309
336
  case zongji_utils.eventIsRotation(evt):
310
337
  // The first event when starting replication is a synthetic Rotate event
311
- // It describes the last binlog file and position that the replica client processed
338
+ // It describes the the position and file that the replica requested to start from
339
+ const isNewFile = this.binLogPosition.filename !== evt.binlogName;
340
+
312
341
  this.binLogPosition.filename = evt.binlogName;
313
- this.binLogPosition.offset = evt.nextPosition !== 0 ? evt.nextPosition : evt.position;
342
+ this.binLogPosition.offset = evt.position;
343
+
314
344
  await this.eventHandler.onRotate();
315
345
 
316
- const newFile = this.binLogPosition.filename !== evt.binlogName;
317
- if (newFile) {
346
+ if (isNewFile) {
318
347
  this.logger.info(
319
348
  `Processed Rotate event. New BinLog file is: ${this.binLogPosition.filename}:${this.binLogPosition.offset}`
320
349
  );
@@ -359,11 +388,7 @@ export class BinLogListener {
359
388
  break;
360
389
  case zongji_utils.eventIsXid(evt):
361
390
  this.isTransactionOpen = false;
362
- this.binLogPosition.offset = evt.nextPosition;
363
- const LSN = new common.ReplicatedGTID({
364
- raw_gtid: this.currentGTID.raw,
365
- position: this.binLogPosition
366
- }).comparable;
391
+ const LSN = this.advanceCommitPosition(evt.nextPosition);
367
392
  await this.eventHandler.onCommit(LSN);
368
393
  this.logger.info(`Processed Xid event - transaction complete. LSN: ${LSN}.`);
369
394
  break;
@@ -376,6 +401,21 @@ export class BinLogListener {
376
401
  };
377
402
  }
378
403
 
404
+ /**
405
+ * Advances the binlog position to the end of a committed transaction and updates the currentGTID to match.
406
+ * This ensures subsequent heartbeat keepalives report an LSN that is not behind the last commit LSN,
407
+ * which would otherwise block checkpoint creation until the next transaction arrives.
408
+ * Returns the commit LSN.
409
+ */
410
+ private advanceCommitPosition(nextPosition: number): string {
411
+ this.binLogPosition.offset = nextPosition;
412
+ this.currentGTID = new common.ReplicatedGTID({
413
+ rawGtid: this.currentGTID.raw,
414
+ position: { ...this.binLogPosition }
415
+ });
416
+ return this.currentGTID.comparable;
417
+ }
418
+
379
419
  private async processQueryEvent(event: BinLogQueryEvent): Promise<void> {
380
420
  const { query, nextPosition } = event;
381
421
 
@@ -398,11 +438,7 @@ export class BinLogListener {
398
438
  // DDL queries are auto commited, but do not come with a corresponding Xid event, in those cases we trigger a manual commit if we are not already in a transaction.
399
439
  // Some DDL queries include row events, and in those cases will include a Xid event.
400
440
  if (!this.isTransactionOpen) {
401
- this.binLogPosition.offset = nextPosition;
402
- const LSN = new common.ReplicatedGTID({
403
- raw_gtid: this.currentGTID.raw,
404
- position: this.binLogPosition
405
- }).comparable;
441
+ const LSN = this.advanceCommitPosition(nextPosition);
406
442
  await this.eventHandler.onCommit(LSN);
407
443
  }
408
444
 
@@ -419,11 +455,7 @@ export class BinLogListener {
419
455
  await this.restartZongji();
420
456
  }
421
457
  } else if (!this.isTransactionOpen) {
422
- this.binLogPosition.offset = nextPosition;
423
- const LSN = new common.ReplicatedGTID({
424
- raw_gtid: this.currentGTID.raw,
425
- position: this.binLogPosition
426
- }).comparable;
458
+ const LSN = this.advanceCommitPosition(nextPosition);
427
459
  await this.eventHandler.onCommit(LSN);
428
460
  }
429
461
  }
@@ -111,6 +111,24 @@ describe('BinlogListener tests', { timeout: 60_000 }, () => {
111
111
  expect(eventHandler.lastKeepAlive).toEqual(binLogListener.options.startGTID.comparable);
112
112
  });
113
113
 
114
+ test('Keepalive LSN after a commit is not less than the commit LSN', async () => {
115
+ binLogListener.options.keepAliveInactivitySeconds = 1;
116
+ await binLogListener.start();
117
+
118
+ await insertRows(connectionManager, 1);
119
+ await vi.waitFor(() => expect(eventHandler.commitCount).equals(1), { timeout: 5000 });
120
+ const commitLsn = eventHandler.lastCommitLsn!;
121
+
122
+ // Wait for a heartbeat keepalive that arrives after the commit.
123
+ // A keepalive LSN behind the commit LSN blocks checkpoint creation until the next transaction arrives.
124
+ await vi.waitFor(() => expect(eventHandler.lastKeepAlive && eventHandler.lastKeepAlive >= commitLsn).toBeTruthy(), {
125
+ timeout: 10_000
126
+ });
127
+ await binLogListener.stop();
128
+ // No binlog rotation happens in this test, so the keepalive LSN should exactly match the commit LSN
129
+ expect(eventHandler.lastKeepAlive).toEqual(commitLsn);
130
+ });
131
+
114
132
  test('Schema change event: Rename table', async () => {
115
133
  await binLogListener.start();
116
134
  await connectionManager.query(`ALTER TABLE test_DATA RENAME test_DATA_new`);
@@ -167,7 +167,7 @@ export class BinlogStreamTestContext {
167
167
  const checkpoint = await this.getCheckpoint(options);
168
168
  const syncConfigContent = this.getSyncConfigContent();
169
169
  const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncConfigContent, bucket, start));
170
- return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
170
+ return test_utils.getBatchArray(this.storage!.getBucketDataBatch(checkpoint, map));
171
171
  }
172
172
 
173
173
  async getBucketData(
@@ -183,7 +183,7 @@ export class BinlogStreamTestContext {
183
183
  const checkpoint = await this.getCheckpoint(options);
184
184
  const map = [bucketRequest(syncConfigContent, bucket, start)];
185
185
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
186
- const batches = await test_utils.fromAsync(batch);
186
+ const batches = await test_utils.getBatchArray(batch);
187
187
  return batches[0]?.chunkData.data ?? [];
188
188
  }
189
189
  }