@powersync/service-module-mysql 0.15.0 → 0.16.1

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 (41) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/api/MySQLRouteAPIAdapter.js +3 -3
  3. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
  4. package/dist/common/ReplicatedGTID.d.ts +16 -9
  5. package/dist/common/ReplicatedGTID.js +49 -25
  6. package/dist/common/ReplicatedGTID.js.map +1 -1
  7. package/dist/common/check-source-configuration.js +11 -0
  8. package/dist/common/check-source-configuration.js.map +1 -1
  9. package/dist/common/read-executed-gtid.d.ts +11 -1
  10. package/dist/common/read-executed-gtid.js +67 -5
  11. package/dist/common/read-executed-gtid.js.map +1 -1
  12. package/dist/replication/BinLogReplicationJob.js +3 -1
  13. package/dist/replication/BinLogReplicationJob.js.map +1 -1
  14. package/dist/replication/BinLogStream.d.ts +8 -0
  15. package/dist/replication/BinLogStream.js +58 -18
  16. package/dist/replication/BinLogStream.js.map +1 -1
  17. package/dist/replication/MySQLConnectionManager.js +6 -0
  18. package/dist/replication/MySQLConnectionManager.js.map +1 -1
  19. package/dist/replication/zongji/BinLogListener.d.ts +41 -2
  20. package/dist/replication/zongji/BinLogListener.js +104 -30
  21. package/dist/replication/zongji/BinLogListener.js.map +1 -1
  22. package/dist/utils/mysql-utils.d.ts +6 -0
  23. package/dist/utils/mysql-utils.js +10 -0
  24. package/dist/utils/mysql-utils.js.map +1 -1
  25. package/package.json +10 -10
  26. package/src/api/MySQLRouteAPIAdapter.ts +4 -4
  27. package/src/common/ReplicatedGTID.ts +65 -32
  28. package/src/common/check-source-configuration.ts +15 -0
  29. package/src/common/read-executed-gtid.ts +76 -6
  30. package/src/replication/BinLogReplicationJob.ts +3 -1
  31. package/src/replication/BinLogStream.ts +63 -18
  32. package/src/replication/MySQLConnectionManager.ts +6 -0
  33. package/src/replication/zongji/BinLogListener.ts +131 -30
  34. package/src/utils/mysql-utils.ts +11 -0
  35. package/test/src/BinLogListener.test.ts +140 -1
  36. package/test/src/ReplicatedGTID.test.ts +138 -0
  37. package/test/src/check-source-configuration.test.ts +70 -0
  38. package/test/src/mysql-utils.test.ts +19 -1
  39. package/test/src/read-executed-gtid.test.ts +188 -0
  40. package/test/src/util.ts +27 -2
  41. package/tsconfig.tsbuildinfo +1 -1
@@ -73,10 +73,14 @@ 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();
79
81
 
82
+ private binLogListener: BinLogListener | null = null;
83
+
80
84
  constructor(private options: BinLogStreamOptions) {
81
85
  this.logger = options.logger ?? defaultLogger;
82
86
  this.storage = options.storage;
@@ -220,16 +224,23 @@ export class BinLogStream {
220
224
  this.logger.info(`Initial replication already done.`);
221
225
 
222
226
  if (lastKnowGTID) {
223
- // Check if the specific binlog file is still available. If it isn't, we need to snapshot again.
224
227
  const connection = await this.connections.getConnection();
225
228
  try {
226
- const isAvailable = await common.isBinlogStillAvailable(connection, lastKnowGTID.position.filename);
229
+ // Check if the active server uuid matches the one in the GTID
230
+ if (this.activeServerUuid !== lastKnowGTID.serverUuid) {
231
+ this.logger.info(
232
+ `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.`
233
+ );
234
+ return false;
235
+ }
236
+
237
+ const isAvailable = await common.isGtidPositionStillAvailable(connection, lastKnowGTID);
227
238
  if (!isAvailable) {
228
239
  this.logger.info(
229
- `BinLog file ${lastKnowGTID.position.filename} is no longer available, starting initial replication again.`
240
+ `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
241
  );
242
+ return false;
231
243
  }
232
- return isAvailable;
233
244
  } finally {
234
245
  connection.release();
235
246
  }
@@ -267,9 +278,10 @@ export class BinLogStream {
267
278
  const flushResults = await this.storage.startBatch(
268
279
  {
269
280
  logger: this.logger,
270
- zeroLSN: common.ReplicatedGTID.ZERO.comparable,
281
+ zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
271
282
  defaultSchema: this.defaultSchema,
272
- storeCurrentData: false
283
+ storeCurrentData: false,
284
+ signal: this.abortSignal
273
285
  },
274
286
  async (batch) => {
275
287
  for (let tablePattern of sourceTables) {
@@ -296,8 +308,8 @@ export class BinLogStream {
296
308
  }
297
309
 
298
310
  if (lastOp != null) {
299
- // Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
300
- await this.storage.populatePersistentChecksumCache({
311
+ // Compact storage _after_ initial replication, but _before_ we switch to this replication stream.
312
+ await this.storage.compactInitialReplication({
301
313
  // No checkpoint yet, but we do have the opId.
302
314
  maxOpId: lastOp,
303
315
  signal: this.abortSignal
@@ -363,7 +375,19 @@ export class BinLogStream {
363
375
  }
364
376
  }
365
377
 
378
+ private async ensureActiveServerUuid() {
379
+ if (this.activeServerUuid == null) {
380
+ const connection = await this.connections.getConnection();
381
+ try {
382
+ this.activeServerUuid = await common.readServerUuid(connection);
383
+ } finally {
384
+ connection.release();
385
+ }
386
+ }
387
+ }
388
+
366
389
  async initReplication() {
390
+ await this.ensureActiveServerUuid();
367
391
  const connection = await this.connections.getConnection();
368
392
  const errors = await common.checkSourceConfiguration(connection);
369
393
  connection.release();
@@ -382,9 +406,10 @@ export class BinLogStream {
382
406
  await this.storage.startBatch(
383
407
  {
384
408
  logger: this.logger,
385
- zeroLSN: common.ReplicatedGTID.ZERO.comparable,
409
+ zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
386
410
  defaultSchema: this.defaultSchema,
387
- storeCurrentData: false
411
+ storeCurrentData: false,
412
+ signal: this.abortSignal
388
413
  },
389
414
  async (batch) => {
390
415
  for (let tablePattern of sourceTables) {
@@ -406,21 +431,31 @@ export class BinLogStream {
406
431
  }
407
432
 
408
433
  async streamChanges() {
434
+ await this.ensureActiveServerUuid();
409
435
  const serverId = createRandomServerId(this.storage.replicationStreamId);
410
436
 
411
437
  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}`);
438
+ let fromGTID: common.ReplicatedGTID;
439
+ try {
440
+ const { resumeLsn: resume_lsn } = await this.storage.getStatus();
441
+ if (resume_lsn) {
442
+ this.logger.info(`Existing resume LSN found: ${resume_lsn}`);
443
+ }
444
+ fromGTID = resume_lsn
445
+ ? common.ReplicatedGTID.fromSerialized(resume_lsn)
446
+ : await common.readExecutedGtid(connection);
447
+ } finally {
448
+ connection.release();
415
449
  }
416
- const fromGTID = resume_lsn
417
- ? common.ReplicatedGTID.fromSerialized(resume_lsn)
418
- : await common.readExecutedGtid(connection);
419
- connection.release();
420
450
 
421
451
  if (!this.stopped) {
422
452
  await this.storage.startBatch(
423
- { zeroLSN: common.ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: false },
453
+ {
454
+ zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
455
+ defaultSchema: this.defaultSchema,
456
+ storeCurrentData: false,
457
+ signal: this.abortSignal
458
+ },
424
459
  async (batch) => {
425
460
  const binlogEventHandler = this.createBinlogEventHandler(batch);
426
461
  const binlogListener = new BinLogListener({
@@ -429,8 +464,10 @@ export class BinLogStream {
429
464
  startGTID: fromGTID,
430
465
  connectionManager: this.connections,
431
466
  serverId: serverId,
467
+ activeServerUuid: this.activeServerUuid!,
432
468
  eventHandler: binlogEventHandler
433
469
  });
470
+ this.binLogListener = binlogListener;
434
471
 
435
472
  this.abortSignal.addEventListener(
436
473
  'abort',
@@ -666,6 +703,14 @@ export class BinLogStream {
666
703
  return this.replicationLag.getLagMillis();
667
704
  }
668
705
 
706
+ /**
707
+ * Probe the liveness of the BinLog Listener's control connection. Called from the replication
708
+ * job's keepAlive. Does nothing before streaming starts (during the initial snapshot).
709
+ */
710
+ probeControlConnection(): void {
711
+ this.binLogListener?.probeControlConnection();
712
+ }
713
+
669
714
  async tryRollback(promiseConnection: mysqlPromise.Connection) {
670
715
  try {
671
716
  await promiseConnection.query('ROLLBACK');
@@ -49,11 +49,17 @@ export class MySQLConnectionManager extends BaseObserver<MySQLConnectionManagerL
49
49
  * Create a new replication listener
50
50
  */
51
51
  createBinlogListener(): ZongJi {
52
+ // These options apply to both the binlog connection and the control connection Zongji creates.
52
53
  const listener = new ZongJi({
53
54
  host: this.options.hostname,
54
55
  port: this.options.port,
55
56
  user: this.options.username,
56
57
  password: this.options.password,
58
+ // TCP keepalive is disabled by default in @vlasky/mysql. Without it, the idle control
59
+ // connection can be silently dropped by stateful firewalls, freezing replication on the
60
+ // next table metadata query until the TCP retransmission timeout (~950s).
61
+ enableKeepAlive: true,
62
+ keepAliveInitialDelay: mysql_utils.TCP_KEEPALIVE_INITIAL_DELAY,
57
63
  // We want to avoid parsing date/time values to Date, because that drops sub-millisecond precision.
58
64
  dateStrings: true,
59
65
  timeZone: 'Z'
@@ -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';
@@ -33,6 +33,19 @@ const { Parser } = pkg;
33
33
  * Seconds of inactivity after which a keepalive event is sent by the MySQL server.
34
34
  */
35
35
  export const KEEPALIVE_INACTIVITY_THRESHOLD = 30;
36
+
37
+ /**
38
+ * Maximum time in milliseconds to wait for Zongji to stop before force-closing its control connection.
39
+ */
40
+ export const ZONGJI_STOP_TIMEOUT = 5_000;
41
+
42
+ /**
43
+ * Maximum time in milliseconds a control connection liveness probe may execute before the
44
+ * connection is considered dead. Time the probe spends queued behind other control queries
45
+ * does not count towards this.
46
+ */
47
+ export const CTRL_CONNECTION_PROBE_TIMEOUT = 5_000;
48
+
36
49
  export type Row = Record<string, any>;
37
50
 
38
51
  /**
@@ -76,10 +89,18 @@ export interface BinLogListenerOptions {
76
89
  connectionManager: MySQLConnectionManager;
77
90
  eventHandler: BinLogEventHandler;
78
91
  sourceTables: TablePattern[];
92
+ /**
93
+ * Id that identifies this replication client.
94
+ */
79
95
  serverId: number;
96
+ /**
97
+ * The server uuid of the source MySQL server that is being replicated.
98
+ */
99
+ activeServerUuid: string;
80
100
  startGTID: common.ReplicatedGTID;
81
101
  logger?: Logger;
82
102
  keepAliveInactivitySeconds?: number;
103
+ ctrlConnectionProbeTimeoutMs?: number;
83
104
  }
84
105
 
85
106
  /**
@@ -88,8 +109,6 @@ export interface BinLogListenerOptions {
88
109
  */
89
110
  export class BinLogListener {
90
111
  private sqlParser: ParserType;
91
- private connectionManager: MySQLConnectionManager;
92
- private eventHandler: BinLogEventHandler;
93
112
  private binLogPosition: common.BinLogPosition;
94
113
  private currentGTID: common.ReplicatedGTID;
95
114
  private logger: Logger;
@@ -99,8 +118,12 @@ export class BinLogListener {
99
118
  private isStopped: boolean = false;
100
119
  private isStopping: boolean = false;
101
120
 
121
+ // Set while a control connection probe is awaiting a response, so repeated probes do not pile up behind it.
122
+ private probePending: boolean = false;
123
+
102
124
  // Flag to indicate if are currently in a transaction that involves multiple row mutation events.
103
125
  private isTransactionOpen = false;
126
+
104
127
  zongji: ZongJi;
105
128
  processingQueue: async.QueueObject<BinLogEvent>;
106
129
 
@@ -111,9 +134,8 @@ export class BinLogListener {
111
134
 
112
135
  constructor(public options: BinLogListenerOptions) {
113
136
  this.logger = options.logger ?? defaultLogger;
114
- this.connectionManager = options.connectionManager;
115
- this.eventHandler = options.eventHandler;
116
- this.binLogPosition = options.startGTID.position;
137
+ // Copy the position: the listener mutates it as events are processed, and the caller's startGTID must not change
138
+ this.binLogPosition = { ...options.startGTID.position };
117
139
  this.currentGTID = options.startGTID;
118
140
  this.sqlParser = new Parser();
119
141
  this.processingQueue = this.createProcessingQueue();
@@ -122,6 +144,18 @@ export class BinLogListener {
122
144
  this.databaseFilter = this.createDatabaseFilter(options.sourceTables);
123
145
  }
124
146
 
147
+ private get connectionManager(): MySQLConnectionManager {
148
+ return this.options.connectionManager;
149
+ }
150
+
151
+ private get eventHandler(): BinLogEventHandler {
152
+ return this.options.eventHandler;
153
+ }
154
+
155
+ private get activeServerUuid(): string {
156
+ return this.options.activeServerUuid;
157
+ }
158
+
125
159
  /**
126
160
  * The queue memory limit in bytes as defined in the connection options.
127
161
  * @private
@@ -205,12 +239,29 @@ export class BinLogListener {
205
239
  private async stopZongji(): Promise<void> {
206
240
  if (!this.zongji.stopped) {
207
241
  this.logger.info('Stopping BinLog Listener...');
208
- await new Promise<void>((resolve) => {
242
+ const controlConnection = this.zongji.ctrlConnection;
243
+ let stopped = false;
244
+ const stopPromise = new Promise<void>((resolve) => {
209
245
  this.zongji.once('stopped', () => {
246
+ stopped = true;
210
247
  resolve();
211
248
  });
212
249
  this.zongji.stop();
213
250
  });
251
+ // Zongji only emits 'stopped' once the KILL query on its control connection has completed.
252
+ // If that connection has been dead for a while, the query can block on TCP retransmissions
253
+ // for many minutes, so we destroy the socket after a timeout to unblock the stop.
254
+ const timeout = timers.setTimeout(ZONGJI_STOP_TIMEOUT, undefined, { ref: false }).then(() => {
255
+ if (!stopped) {
256
+ this.logger.warn('Timed out waiting for the BinLog Listener to stop. Closing the control connection.');
257
+ controlConnection._socket?.destroy();
258
+ }
259
+ });
260
+ await Promise.race([stopPromise, timeout]);
261
+ // Zongji destroys the control connection when it stops, which drops pending query callbacks:
262
+ // a probe waiting on this connection would otherwise stay pending forever and disable
263
+ // probing after a restart.
264
+ this.probePending = false;
214
265
  this.logger.info('BinLog Listener stopped.');
215
266
  }
216
267
  }
@@ -236,6 +287,41 @@ export class BinLogListener {
236
287
  }
237
288
  }
238
289
 
290
+ /**
291
+ * The binlog connection is kept alive by the MySQL server heartbeat, but the control connection
292
+ * carries no traffic between metadata queries. TCP keepalive stops it from being dropped when idle,
293
+ * but detects an already dead connection slowly, so the replication job's keepAlive additionally
294
+ * probes it with a lightweight query and stops the listener (restarting replication) if the probe
295
+ * does not respond in time.
296
+ *
297
+ * The driver starts the query timeout when the query begins executing, not when it is queued, so a
298
+ * probe waiting behind a legitimately slow metadata query does not produce a false failure. A probe
299
+ * queued on a dead socket is failed together with the queued query by TCP keepalive on the socket.
300
+ */
301
+ public probeControlConnection(): void {
302
+ if (this.probePending || this.zongji.stopped || this.isStopped || this.isStopping) {
303
+ return;
304
+ }
305
+ this.probePending = true;
306
+ const controlConnection = this.zongji.ctrlConnection;
307
+ const timeout = this.options.ctrlConnectionProbeTimeoutMs ?? CTRL_CONNECTION_PROBE_TIMEOUT;
308
+ controlConnection.query({ sql: 'SELECT 1', timeout }, (error) => {
309
+ this.probePending = false;
310
+ // Only act if the probe failed on a connection that is still supposed to be alive.
311
+ if (
312
+ error != null &&
313
+ this.zongji.ctrlConnection === controlConnection &&
314
+ !this.zongji.stopped &&
315
+ !(this.isStopped || this.isStopping)
316
+ ) {
317
+ this.logger.warn('MySQL control connection is unresponsive. Stopping the BinLog Listener...');
318
+ this.listenerError = new Error('MySQL control connection is unresponsive.');
319
+ controlConnection._socket?.destroy();
320
+ this.stop();
321
+ }
322
+ });
323
+ }
324
+
239
325
  private createProcessingQueue(): async.QueueObject<BinLogEvent> {
240
326
  const queue = async.queue(this.createQueueWorker(), 1);
241
327
 
@@ -292,29 +378,41 @@ export class BinLogListener {
292
378
  return async (evt: BinLogEvent) => {
293
379
  switch (true) {
294
380
  case zongji_utils.eventIsGTIDLog(evt):
295
- this.currentGTID = common.ReplicatedGTID.fromBinLogEvent({
296
- raw_gtid: {
297
- server_id: evt.serverId,
298
- transaction_range: evt.transactionRange
381
+ const transactionGTID = common.ReplicatedGTID.fromBinLogEvent({
382
+ rawGtid: {
383
+ serverUuid: evt.serverId, // The server uuid this transaction originated from
384
+ transactionId: evt.transactionRange
299
385
  },
300
386
  position: {
301
387
  filename: this.binLogPosition.filename,
302
388
  offset: evt.nextPosition
303
389
  }
304
390
  });
391
+
392
+ if (transactionGTID.serverUuid !== this.activeServerUuid) {
393
+ throw new ReplicationAssertionError(
394
+ `Detected a transaction from a different MySQL server UUID: ${transactionGTID.serverUuid} than the server that is currently being replicated from: ${this.activeServerUuid}. ` +
395
+ `A re-snapshot is required to ensure consistency.`
396
+ );
397
+ }
398
+
399
+ this.currentGTID = transactionGTID;
305
400
  this.binLogPosition.offset = evt.nextPosition;
401
+
306
402
  await this.eventHandler.onTransactionStart({ timestamp: new Date(evt.timestamp) });
307
403
  this.logger.info(`Processed GTID event: ${this.currentGTID.comparable}`);
308
404
  break;
309
405
  case zongji_utils.eventIsRotation(evt):
310
406
  // 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
407
+ // It describes the the position and file that the replica requested to start from
408
+ const isNewFile = this.binLogPosition.filename !== evt.binlogName;
409
+
312
410
  this.binLogPosition.filename = evt.binlogName;
313
- this.binLogPosition.offset = evt.nextPosition !== 0 ? evt.nextPosition : evt.position;
411
+ this.binLogPosition.offset = evt.position;
412
+
314
413
  await this.eventHandler.onRotate();
315
414
 
316
- const newFile = this.binLogPosition.filename !== evt.binlogName;
317
- if (newFile) {
415
+ if (isNewFile) {
318
416
  this.logger.info(
319
417
  `Processed Rotate event. New BinLog file is: ${this.binLogPosition.filename}:${this.binLogPosition.offset}`
320
418
  );
@@ -359,11 +457,7 @@ export class BinLogListener {
359
457
  break;
360
458
  case zongji_utils.eventIsXid(evt):
361
459
  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;
460
+ const LSN = this.advanceCommitPosition(evt.nextPosition);
367
461
  await this.eventHandler.onCommit(LSN);
368
462
  this.logger.info(`Processed Xid event - transaction complete. LSN: ${LSN}.`);
369
463
  break;
@@ -376,6 +470,21 @@ export class BinLogListener {
376
470
  };
377
471
  }
378
472
 
473
+ /**
474
+ * Advances the binlog position to the end of a committed transaction and updates the currentGTID to match.
475
+ * This ensures subsequent heartbeat keepalives report an LSN that is not behind the last commit LSN,
476
+ * which would otherwise block checkpoint creation until the next transaction arrives.
477
+ * Returns the commit LSN.
478
+ */
479
+ private advanceCommitPosition(nextPosition: number): string {
480
+ this.binLogPosition.offset = nextPosition;
481
+ this.currentGTID = new common.ReplicatedGTID({
482
+ rawGtid: this.currentGTID.raw,
483
+ position: { ...this.binLogPosition }
484
+ });
485
+ return this.currentGTID.comparable;
486
+ }
487
+
379
488
  private async processQueryEvent(event: BinLogQueryEvent): Promise<void> {
380
489
  const { query, nextPosition } = event;
381
490
 
@@ -398,11 +507,7 @@ export class BinLogListener {
398
507
  // 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
508
  // Some DDL queries include row events, and in those cases will include a Xid event.
400
509
  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;
510
+ const LSN = this.advanceCommitPosition(nextPosition);
406
511
  await this.eventHandler.onCommit(LSN);
407
512
  }
408
513
 
@@ -419,11 +524,7 @@ export class BinLogListener {
419
524
  await this.restartZongji();
420
525
  }
421
526
  } 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;
527
+ const LSN = this.advanceCommitPosition(nextPosition);
427
528
  await this.eventHandler.onCommit(LSN);
428
529
  }
429
530
  }
@@ -12,6 +12,13 @@ export type RetriedQueryOptions = {
12
12
  retries?: number;
13
13
  };
14
14
 
15
+ /**
16
+ * TCP keepalive initial delay in milliseconds for connections to the MySQL server.
17
+ * Keepalive prevents long-lived idle connections from being silently dropped by stateful
18
+ * firewalls, which commonly time out idle flows after an hour.
19
+ */
20
+ export const TCP_KEEPALIVE_INITIAL_DELAY = 40_000;
21
+
15
22
  /**
16
23
  * Retry a simple query - up to 2 attempts total.
17
24
  */
@@ -54,6 +61,10 @@ export function createPool(config: types.NormalizedMySQLConnectionConfig, option
54
61
  timezone: 'Z', // Ensure no auto timezone manipulation of the dates occur
55
62
  jsonStrings: true, // Return JSON columns as strings
56
63
  dateStrings: true, // We parse and format them ourselves
64
+ // mysql2 enables TCP keepalive by default, but without an initial delay the OS default of
65
+ // 7200 seconds applies, which is too late for common 3600 second firewall idle timeouts.
66
+ enableKeepAlive: true,
67
+ keepAliveInitialDelay: TCP_KEEPALIVE_INITIAL_DELAY,
57
68
  // Apply URL connection parameters (explicit options override these via spread below)
58
69
  ...(params.connectTimeout != null ? { connectTimeout: params.connectTimeout } : {}),
59
70
  ...(params.connectionLimit != null ? { connectionLimit: params.connectionLimit } : {}),
@@ -1,6 +1,12 @@
1
1
  import { MySQLConnectionManager } from '@module/replication/MySQLConnectionManager.js';
2
2
  import { BinLogListener, SchemaChange, SchemaChangeType } from '@module/replication/zongji/BinLogListener.js';
3
- import { getMySQLVersion, qualifiedMySQLTable, satisfiesVersion } from '@module/utils/mysql-utils.js';
3
+ import {
4
+ getMySQLVersion,
5
+ qualifiedMySQLTable,
6
+ satisfiesVersion,
7
+ TCP_KEEPALIVE_INITIAL_DELAY
8
+ } from '@module/utils/mysql-utils.js';
9
+ import { MySQLConnection } from '@powersync/mysql-zongji';
4
10
  import { TablePattern } from '@powersync/service-sync-rules';
5
11
  import crypto from 'crypto';
6
12
  import { v4 as uuid } from 'uuid';
@@ -13,6 +19,11 @@ import {
13
19
  TestBinLogEventHandler
14
20
  } from './util.js';
15
21
 
22
+ // The zongji type definitions do not expose the connection config.
23
+ type ConnectionWithConfig = MySQLConnection & {
24
+ config: { enableKeepAlive?: boolean; keepAliveInitialDelay?: number };
25
+ };
26
+
16
27
  describe('BinlogListener tests', { timeout: 60_000 }, () => {
17
28
  const MAX_QUEUE_CAPACITY_MB = 1;
18
29
  const BINLOG_LISTENER_CONNECTION_OPTIONS = {
@@ -59,6 +70,116 @@ describe('BinlogListener tests', { timeout: 60_000 }, () => {
59
70
 
60
71
  expect(stopSpy).toHaveBeenCalled();
61
72
  expect(queueStopSpy).toHaveBeenCalled();
73
+ // Zongji destroys its control connection when stopping.
74
+ expect(binLogListener.zongji.ctrlConnection.state).toBe('disconnected');
75
+ });
76
+
77
+ test('TCP keepalive is enabled on the binlog and control connections', async () => {
78
+ // Without keepalive, the control connection can idle for hours and be silently dropped by
79
+ // stateful firewalls. The next metadata query then blocks until the kernel gives up on TCP
80
+ // retransmissions, freezing the whole binlog pipeline for ~15 minutes.
81
+ const { connection } = binLogListener.zongji as unknown as { connection: ConnectionWithConfig };
82
+ const controlConnection = binLogListener.zongji.ctrlConnection as ConnectionWithConfig;
83
+
84
+ for (const conn of [connection, controlConnection]) {
85
+ expect(conn.config.enableKeepAlive).toBe(true);
86
+ expect(conn.config.keepAliveInitialDelay).toBe(TCP_KEEPALIVE_INITIAL_DELAY);
87
+ }
88
+ });
89
+
90
+ test('Stop completes when the control connection is unresponsive', { timeout: 20_000 }, async () => {
91
+ await binLogListener.start();
92
+
93
+ // Simulate a control connection that was silently dropped by the network: the KILL query
94
+ // issued by zongji.stop() never gets a response.
95
+ vi.spyOn(binLogListener.zongji.ctrlConnection, 'query').mockImplementation(() => {});
96
+
97
+ await binLogListener.stop();
98
+
99
+ expect(binLogListener.zongji.stopped).toBeTruthy();
100
+ });
101
+
102
+ test('Probe on a healthy control connection passes', { timeout: 20_000 }, async () => {
103
+ await binLogListener.start();
104
+
105
+ // No mocking: the real driver must accept the options form of query and answer the probe.
106
+ const controlConnection = binLogListener.zongji.ctrlConnection;
107
+ const realQuery = controlConnection.query.bind(controlConnection);
108
+ const probeError = new Promise((resolve) => {
109
+ vi.spyOn(controlConnection, 'query').mockImplementation(((options: any, callback: any) => {
110
+ realQuery(options, (error: any, results: any, fields: any) => {
111
+ callback(error, results, fields);
112
+ resolve(error);
113
+ });
114
+ }) as any);
115
+ });
116
+
117
+ binLogListener.probeControlConnection();
118
+
119
+ expect(await probeError).toBeNull();
120
+ expect(binLogListener.zongji.stopped).toBeFalsy();
121
+
122
+ await binLogListener.stop();
123
+ });
124
+
125
+ test('Probe detects an unresponsive control connection', { timeout: 20_000 }, async () => {
126
+ binLogListener = await createBinlogListener({
127
+ connectionManager,
128
+ sourceTables: [new TablePattern(connectionManager.databaseName, 'test_DATA')],
129
+ eventHandler,
130
+ ctrlConnectionProbeTimeoutMs: 500
131
+ });
132
+ await binLogListener.start();
133
+
134
+ // The probe query starts executing but never gets a response, like a connection that died
135
+ // without either side being notified: the driver's query timeout fires.
136
+ vi.spyOn(binLogListener.zongji.ctrlConnection, 'query').mockImplementation(((_options: any, callback: any) => {
137
+ const error: any = new Error('Query inactivity timeout');
138
+ error.code = 'PROTOCOL_SEQUENCE_TIMEOUT';
139
+ setTimeout(() => callback(error), 10);
140
+ }) as any);
141
+
142
+ const replication = binLogListener.replicateUntilStopped();
143
+ binLogListener.probeControlConnection();
144
+
145
+ await expect(replication).rejects.toThrow('control connection is unresponsive');
146
+ expect(binLogListener.zongji.stopped).toBeTruthy();
147
+ });
148
+
149
+ test('Probe queued behind a busy control connection does not report it dead', { timeout: 20_000 }, async () => {
150
+ binLogListener = await createBinlogListener({
151
+ connectionManager,
152
+ sourceTables: [new TablePattern(connectionManager.databaseName, 'test_DATA')],
153
+ eventHandler,
154
+ ctrlConnectionProbeTimeoutMs: 500
155
+ });
156
+ await binLogListener.start();
157
+
158
+ // The probe never even starts executing, as if queued behind a long-running metadata query on
159
+ // a healthy connection. The probe must not report the connection dead, and further probes must
160
+ // not pile up behind the pending one.
161
+ const querySpy = vi.spyOn(binLogListener.zongji.ctrlConnection, 'query').mockImplementation((() => {}) as any);
162
+
163
+ binLogListener.probeControlConnection();
164
+ binLogListener.probeControlConnection();
165
+
166
+ expect(querySpy).toHaveBeenCalledTimes(1);
167
+ expect(binLogListener.zongji.stopped).toBeFalsy();
168
+
169
+ querySpy.mockRestore();
170
+ await binLogListener.stop();
171
+ });
172
+
173
+ test('Control connection errors stop the listener', { timeout: 20_000 }, async () => {
174
+ await binLogListener.start();
175
+
176
+ const replication = binLogListener.replicateUntilStopped();
177
+ // The driver emits 'error' when the socket fails while no query is pending. Without the
178
+ // forwarding set up in createBinlogListener, this event has no listener and crashes the process.
179
+ (binLogListener.zongji.ctrlConnection as any).emit('error', new Error('Control connection failure'));
180
+
181
+ await expect(replication).rejects.toThrow('Control connection failure');
182
+ expect(binLogListener.zongji.stopped).toBeTruthy();
62
183
  });
63
184
 
64
185
  test('Zongji listener is stopped when processing queue reaches maximum memory size', async () => {
@@ -111,6 +232,24 @@ describe('BinlogListener tests', { timeout: 60_000 }, () => {
111
232
  expect(eventHandler.lastKeepAlive).toEqual(binLogListener.options.startGTID.comparable);
112
233
  });
113
234
 
235
+ test('Keepalive LSN after a commit is not less than the commit LSN', async () => {
236
+ binLogListener.options.keepAliveInactivitySeconds = 1;
237
+ await binLogListener.start();
238
+
239
+ await insertRows(connectionManager, 1);
240
+ await vi.waitFor(() => expect(eventHandler.commitCount).equals(1), { timeout: 5000 });
241
+ const commitLsn = eventHandler.lastCommitLsn!;
242
+
243
+ // Wait for a heartbeat keepalive that arrives after the commit.
244
+ // A keepalive LSN behind the commit LSN blocks checkpoint creation until the next transaction arrives.
245
+ await vi.waitFor(() => expect(eventHandler.lastKeepAlive && eventHandler.lastKeepAlive >= commitLsn).toBeTruthy(), {
246
+ timeout: 10_000
247
+ });
248
+ await binLogListener.stop();
249
+ // No binlog rotation happens in this test, so the keepalive LSN should exactly match the commit LSN
250
+ expect(eventHandler.lastKeepAlive).toEqual(commitLsn);
251
+ });
252
+
114
253
  test('Schema change event: Rename table', async () => {
115
254
  await binLogListener.start();
116
255
  await connectionManager.query(`ALTER TABLE test_DATA RENAME test_DATA_new`);