@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.
- package/CHANGELOG.md +42 -0
- package/dist/api/MySQLRouteAPIAdapter.js +3 -3
- package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
- package/dist/common/ReplicatedGTID.d.ts +16 -9
- package/dist/common/ReplicatedGTID.js +49 -25
- package/dist/common/ReplicatedGTID.js.map +1 -1
- package/dist/common/check-source-configuration.js +11 -0
- package/dist/common/check-source-configuration.js.map +1 -1
- package/dist/common/read-executed-gtid.d.ts +11 -1
- package/dist/common/read-executed-gtid.js +67 -5
- package/dist/common/read-executed-gtid.js.map +1 -1
- package/dist/replication/BinLogReplicationJob.js +3 -1
- package/dist/replication/BinLogReplicationJob.js.map +1 -1
- package/dist/replication/BinLogStream.d.ts +8 -0
- package/dist/replication/BinLogStream.js +58 -18
- package/dist/replication/BinLogStream.js.map +1 -1
- package/dist/replication/MySQLConnectionManager.js +6 -0
- package/dist/replication/MySQLConnectionManager.js.map +1 -1
- package/dist/replication/zongji/BinLogListener.d.ts +41 -2
- package/dist/replication/zongji/BinLogListener.js +104 -30
- package/dist/replication/zongji/BinLogListener.js.map +1 -1
- package/dist/utils/mysql-utils.d.ts +6 -0
- package/dist/utils/mysql-utils.js +10 -0
- package/dist/utils/mysql-utils.js.map +1 -1
- package/package.json +10 -10
- package/src/api/MySQLRouteAPIAdapter.ts +4 -4
- package/src/common/ReplicatedGTID.ts +65 -32
- package/src/common/check-source-configuration.ts +15 -0
- package/src/common/read-executed-gtid.ts +76 -6
- package/src/replication/BinLogReplicationJob.ts +3 -1
- package/src/replication/BinLogStream.ts +63 -18
- package/src/replication/MySQLConnectionManager.ts +6 -0
- package/src/replication/zongji/BinLogListener.ts +131 -30
- package/src/utils/mysql-utils.ts +11 -0
- package/test/src/BinLogListener.test.ts +140 -1
- package/test/src/ReplicatedGTID.test.ts +138 -0
- package/test/src/check-source-configuration.test.ts +70 -0
- package/test/src/mysql-utils.test.ts +19 -1
- package/test/src/read-executed-gtid.test.ts +188 -0
- package/test/src/util.ts +27 -2
- 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
|
-
|
|
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
|
-
`
|
|
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
|
-
//
|
|
300
|
-
await this.storage.
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
115
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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.
|
|
411
|
+
this.binLogPosition.offset = evt.position;
|
|
412
|
+
|
|
314
413
|
await this.eventHandler.onRotate();
|
|
315
414
|
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/mysql-utils.ts
CHANGED
|
@@ -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 {
|
|
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`);
|