@powersync/service-module-mysql 0.16.0 → 0.16.2

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.
@@ -79,6 +79,8 @@ export class BinLogStream {
79
79
 
80
80
  private replicationLag = new ReplicationLagTracker();
81
81
 
82
+ private binLogListener: BinLogListener | null = null;
83
+
82
84
  constructor(private options: BinLogStreamOptions) {
83
85
  this.logger = options.logger ?? defaultLogger;
84
86
  this.storage = options.storage;
@@ -278,7 +280,8 @@ export class BinLogStream {
278
280
  logger: this.logger,
279
281
  zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
280
282
  defaultSchema: this.defaultSchema,
281
- storeCurrentData: false
283
+ storeCurrentData: false,
284
+ signal: this.abortSignal
282
285
  },
283
286
  async (batch) => {
284
287
  for (let tablePattern of sourceTables) {
@@ -305,8 +308,8 @@ export class BinLogStream {
305
308
  }
306
309
 
307
310
  if (lastOp != null) {
308
- // Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
309
- await this.storage.populatePersistentChecksumCache({
311
+ // Compact storage _after_ initial replication, but _before_ we switch to this replication stream.
312
+ await this.storage.compactInitialReplication({
310
313
  // No checkpoint yet, but we do have the opId.
311
314
  maxOpId: lastOp,
312
315
  signal: this.abortSignal
@@ -405,7 +408,8 @@ export class BinLogStream {
405
408
  logger: this.logger,
406
409
  zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
407
410
  defaultSchema: this.defaultSchema,
408
- storeCurrentData: false
411
+ storeCurrentData: false,
412
+ signal: this.abortSignal
409
413
  },
410
414
  async (batch) => {
411
415
  for (let tablePattern of sourceTables) {
@@ -449,7 +453,8 @@ export class BinLogStream {
449
453
  {
450
454
  zeroLSN: common.ReplicatedGTID.ZERO(this.activeServerUuid!).comparable,
451
455
  defaultSchema: this.defaultSchema,
452
- storeCurrentData: false
456
+ storeCurrentData: false,
457
+ signal: this.abortSignal
453
458
  },
454
459
  async (batch) => {
455
460
  const binlogEventHandler = this.createBinlogEventHandler(batch);
@@ -462,6 +467,7 @@ export class BinLogStream {
462
467
  activeServerUuid: this.activeServerUuid!,
463
468
  eventHandler: binlogEventHandler
464
469
  });
470
+ this.binLogListener = binlogListener;
465
471
 
466
472
  this.abortSignal.addEventListener(
467
473
  'abort',
@@ -697,6 +703,14 @@ export class BinLogStream {
697
703
  return this.replicationLag.getLagMillis();
698
704
  }
699
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
+
700
714
  async tryRollback(promiseConnection: mysqlPromise.Connection) {
701
715
  try {
702
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'
@@ -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
  /**
@@ -87,6 +100,7 @@ export interface BinLogListenerOptions {
87
100
  startGTID: common.ReplicatedGTID;
88
101
  logger?: Logger;
89
102
  keepAliveInactivitySeconds?: number;
103
+ ctrlConnectionProbeTimeoutMs?: number;
90
104
  }
91
105
 
92
106
  /**
@@ -104,6 +118,9 @@ export class BinLogListener {
104
118
  private isStopped: boolean = false;
105
119
  private isStopping: boolean = false;
106
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
+
107
124
  // Flag to indicate if are currently in a transaction that involves multiple row mutation events.
108
125
  private isTransactionOpen = false;
109
126
 
@@ -222,12 +239,29 @@ export class BinLogListener {
222
239
  private async stopZongji(): Promise<void> {
223
240
  if (!this.zongji.stopped) {
224
241
  this.logger.info('Stopping BinLog Listener...');
225
- await new Promise<void>((resolve) => {
242
+ const controlConnection = this.zongji.ctrlConnection;
243
+ let stopped = false;
244
+ const stopPromise = new Promise<void>((resolve) => {
226
245
  this.zongji.once('stopped', () => {
246
+ stopped = true;
227
247
  resolve();
228
248
  });
229
249
  this.zongji.stop();
230
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;
231
265
  this.logger.info('BinLog Listener stopped.');
232
266
  }
233
267
  }
@@ -253,6 +287,41 @@ export class BinLogListener {
253
287
  }
254
288
  }
255
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
+
256
325
  private createProcessingQueue(): async.QueueObject<BinLogEvent> {
257
326
  const queue = async.queue(this.createQueueWorker(), 1);
258
327
 
@@ -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 () => {
@@ -1,4 +1,5 @@
1
- import { isVersionAtLeast } from '@module/utils/mysql-utils.js';
1
+ import * as types from '@module/types/types.js';
2
+ import { createPool, isVersionAtLeast, TCP_KEEPALIVE_INITIAL_DELAY } from '@module/utils/mysql-utils.js';
2
3
  import { describe, expect, test } from 'vitest';
3
4
 
4
5
  describe('MySQL Utility Tests', () => {
@@ -14,4 +15,21 @@ describe('MySQL Utility Tests', () => {
14
15
  expect(isVersionAtLeast(olderVersion, '8.0')).toBeFalsy();
15
16
  expect(isVersionAtLeast(improperSemver, '5.7')).toBeTruthy();
16
17
  });
18
+
19
+ test('Pool connections are configured with a TCP keepalive initial delay', async () => {
20
+ // mysql2 enables keepalive by default, but without an initial delay the OS default of
21
+ // 7200 seconds applies, which is too late for common 3600 second firewall idle timeouts.
22
+ const config = types.normalizeConnectionConfig({
23
+ type: 'mysql',
24
+ uri: 'mysql://root:password@localhost:3306/mydatabase'
25
+ });
26
+ // The pool is lazy, so no connection is made here.
27
+ const pool = createPool(config);
28
+ const { connectionConfig } = (pool as unknown as { config: { connectionConfig: Record<string, unknown> } }).config;
29
+
30
+ expect(connectionConfig.enableKeepAlive).toBe(true);
31
+ expect(connectionConfig.keepAliveInitialDelay).toBe(TCP_KEEPALIVE_INITIAL_DELAY);
32
+
33
+ await pool.promise().end();
34
+ });
17
35
  });
package/test/src/util.ts CHANGED
@@ -94,6 +94,7 @@ export interface CreateBinlogListenerParams {
94
94
  eventHandler: BinLogEventHandler;
95
95
  sourceTables: TablePattern[];
96
96
  startGTID?: common.ReplicatedGTID;
97
+ ctrlConnectionProbeTimeoutMs?: number;
97
98
  }
98
99
  export async function createBinlogListener(params: CreateBinlogListenerParams): Promise<BinLogListener> {
99
100
  let { connectionManager, eventHandler, sourceTables, startGTID } = params;
@@ -110,7 +111,8 @@ export async function createBinlogListener(params: CreateBinlogListenerParams):
110
111
  startGTID: startGTID!,
111
112
  sourceTables: sourceTables,
112
113
  serverId: createRandomServerId(1),
113
- activeServerUuid: activeServerUuid
114
+ activeServerUuid: activeServerUuid,
115
+ ctrlConnectionProbeTimeoutMs: params.ctrlConnectionProbeTimeoutMs
114
116
  });
115
117
  }
116
118