@powersync/service-module-mysql 0.1.6 → 0.1.8
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 +28 -0
- package/dist/common/mysql-to-sqlite.js +39 -9
- package/dist/common/mysql-to-sqlite.js.map +1 -1
- package/dist/replication/BinLogStream.js +63 -33
- package/dist/replication/BinLogStream.js.map +1 -1
- package/dist/replication/MySQLConnectionManager.js +9 -1
- package/dist/replication/MySQLConnectionManager.js.map +1 -1
- package/dist/utils/mysql-utils.d.ts +2 -0
- package/dist/utils/mysql-utils.js +3 -0
- package/dist/utils/mysql-utils.js.map +1 -1
- package/package.json +9 -7
- package/src/common/mysql-to-sqlite.ts +35 -9
- package/src/replication/BinLogStream.ts +74 -35
- package/src/replication/MySQLConnectionManager.ts +8 -1
- package/src/replication/zongji/zongji.d.ts +10 -0
- package/src/utils/mysql-utils.ts +5 -0
- package/test/src/BinLogStream.test.ts +257 -230
- package/test/src/BinlogStreamUtils.ts +48 -29
- package/test/src/env.ts +1 -0
- package/test/src/mysql-to-sqlite.test.ts +36 -19
- package/test/src/setup.ts +3 -1
- package/test/src/util.ts +6 -25
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -11,7 +11,7 @@ import * as zongji_utils from './zongji/zongji-utils.js';
|
|
|
11
11
|
import { MySQLConnectionManager } from './MySQLConnectionManager.js';
|
|
12
12
|
import { isBinlogStillAvailable, ReplicatedGTID, toColumnDescriptors } from '../common/common-index.js';
|
|
13
13
|
import mysqlPromise from 'mysql2/promise';
|
|
14
|
-
import { createRandomServerId } from '../utils/mysql-utils.js';
|
|
14
|
+
import { createRandomServerId, escapeMysqlTableName } from '../utils/mysql-utils.js';
|
|
15
15
|
|
|
16
16
|
export interface BinLogStreamOptions {
|
|
17
17
|
connections: MySQLConnectionManager;
|
|
@@ -114,8 +114,10 @@ export class BinLogStream {
|
|
|
114
114
|
// Start the snapshot inside a transaction.
|
|
115
115
|
// We use a dedicated connection for this.
|
|
116
116
|
const connection = await this.connections.getStreamingConnection();
|
|
117
|
+
|
|
117
118
|
const promiseConnection = (connection as mysql.Connection).promise();
|
|
118
119
|
try {
|
|
120
|
+
await promiseConnection.query(`SET time_zone = '+00:00'`);
|
|
119
121
|
await promiseConnection.query('BEGIN');
|
|
120
122
|
try {
|
|
121
123
|
gtid = await common.readExecutedGtid(promiseConnection);
|
|
@@ -258,6 +260,8 @@ AND table_type = 'BASE TABLE';`,
|
|
|
258
260
|
'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY'
|
|
259
261
|
);
|
|
260
262
|
await promiseConnection.query<mysqlPromise.RowDataPacket[]>('START TRANSACTION');
|
|
263
|
+
await promiseConnection.query(`SET time_zone = '+00:00'`);
|
|
264
|
+
|
|
261
265
|
const sourceTables = this.syncRules.getSourceTables();
|
|
262
266
|
await this.storage.startBatch(
|
|
263
267
|
{ zeroLSN: ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
|
|
@@ -291,38 +295,36 @@ AND table_type = 'BASE TABLE';`,
|
|
|
291
295
|
logger.info(`Replicating ${table.qualifiedName}`);
|
|
292
296
|
// TODO count rows and log progress at certain batch sizes
|
|
293
297
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.on('fields', (fields: FieldPacket[]) => {
|
|
303
|
-
// Map the columns and their types
|
|
304
|
-
columns = toColumnDescriptors(fields);
|
|
305
|
-
})
|
|
306
|
-
.on('result', async (row) => {
|
|
307
|
-
connection.pause();
|
|
308
|
-
const record = common.toSQLiteRow(row, columns);
|
|
309
|
-
|
|
310
|
-
await batch.save({
|
|
311
|
-
tag: storage.SaveOperationTag.INSERT,
|
|
312
|
-
sourceTable: table,
|
|
313
|
-
before: undefined,
|
|
314
|
-
beforeReplicaId: undefined,
|
|
315
|
-
after: record,
|
|
316
|
-
afterReplicaId: getUuidReplicaIdentityBson(record, table.replicaIdColumns)
|
|
317
|
-
});
|
|
318
|
-
connection.resume();
|
|
319
|
-
Metrics.getInstance().rows_replicated_total.add(1);
|
|
320
|
-
})
|
|
321
|
-
.on('end', async function () {
|
|
322
|
-
await batch.flush();
|
|
323
|
-
resolve();
|
|
324
|
-
});
|
|
298
|
+
// MAX_EXECUTION_TIME(0) hint disables execution timeout for this query
|
|
299
|
+
const query = connection.query(`SELECT /*+ MAX_EXECUTION_TIME(0) */ * FROM ${escapeMysqlTableName(table)}`);
|
|
300
|
+
const stream = query.stream();
|
|
301
|
+
|
|
302
|
+
let columns: Map<string, ColumnDescriptor> | undefined = undefined;
|
|
303
|
+
stream.on('fields', (fields: FieldPacket[]) => {
|
|
304
|
+
// Map the columns and their types
|
|
305
|
+
columns = toColumnDescriptors(fields);
|
|
325
306
|
});
|
|
307
|
+
|
|
308
|
+
for await (let row of stream) {
|
|
309
|
+
if (this.stopped) {
|
|
310
|
+
throw new Error('Abort signal received - initial replication interrupted.');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (columns == null) {
|
|
314
|
+
throw new Error(`No 'fields' event emitted`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const record = common.toSQLiteRow(row, columns!);
|
|
318
|
+
await batch.save({
|
|
319
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
320
|
+
sourceTable: table,
|
|
321
|
+
before: undefined,
|
|
322
|
+
beforeReplicaId: undefined,
|
|
323
|
+
after: record,
|
|
324
|
+
afterReplicaId: getUuidReplicaIdentityBson(record, table.replicaIdColumns)
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
await batch.flush();
|
|
326
328
|
}
|
|
327
329
|
|
|
328
330
|
async replicate() {
|
|
@@ -350,6 +352,18 @@ AND table_type = 'BASE TABLE';`,
|
|
|
350
352
|
const initialReplicationCompleted = await this.checkInitialReplicated();
|
|
351
353
|
if (!initialReplicationCompleted) {
|
|
352
354
|
await this.startInitialReplication();
|
|
355
|
+
} else {
|
|
356
|
+
// We need to find the existing tables, to populate our table cache.
|
|
357
|
+
// This is needed for includeSchema to work correctly.
|
|
358
|
+
const sourceTables = this.syncRules.getSourceTables();
|
|
359
|
+
await this.storage.startBatch(
|
|
360
|
+
{ zeroLSN: ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
|
|
361
|
+
async (batch) => {
|
|
362
|
+
for (let tablePattern of sourceTables) {
|
|
363
|
+
await this.getQualifiedTableNames(batch, tablePattern);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
);
|
|
353
367
|
}
|
|
354
368
|
}
|
|
355
369
|
|
|
@@ -466,11 +480,36 @@ AND table_type = 'BASE TABLE';`,
|
|
|
466
480
|
return;
|
|
467
481
|
}
|
|
468
482
|
|
|
469
|
-
|
|
483
|
+
// Set a heartbeat interval for the Zongji replication connection
|
|
484
|
+
// Zongji does not explicitly handle the heartbeat events - they are categorized as event:unknown
|
|
485
|
+
// The heartbeat events are enough to keep the connection alive for setTimeout to work on the socket.
|
|
486
|
+
await new Promise((resolve, reject) => {
|
|
487
|
+
zongji.connection.query(
|
|
488
|
+
// In nanoseconds, 10^9 = 1s
|
|
489
|
+
'set @master_heartbeat_period=28*1000000000',
|
|
490
|
+
function (error: any, results: any, fields: any) {
|
|
491
|
+
if (error) {
|
|
492
|
+
reject(error);
|
|
493
|
+
} else {
|
|
494
|
+
resolve(results);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
logger.info('Successfully set up replication connection heartbeat...');
|
|
470
500
|
|
|
501
|
+
// The _socket member is only set after a query is run on the connection, so we set the timeout after setting the heartbeat.
|
|
502
|
+
// The timeout here must be greater than the master_heartbeat_period.
|
|
503
|
+
const socket = zongji.connection._socket!;
|
|
504
|
+
socket.setTimeout(60_000, () => {
|
|
505
|
+
socket.destroy(new Error('Replication connection timeout.'));
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
logger.info(`Reading binlog from: ${binLogPositionState.filename}:${binLogPositionState.offset}`);
|
|
471
509
|
// Only listen for changes to tables in the sync rules
|
|
472
510
|
const includedTables = [...this.tableCache.values()].map((table) => table.table);
|
|
473
511
|
zongji.start({
|
|
512
|
+
// We ignore the unknown/heartbeat event since it currently serves no purpose other than to keep the connection alive
|
|
474
513
|
includeEvents: ['tablemap', 'writerows', 'updaterows', 'deleterows', 'xid', 'rotate', 'gtidlog'],
|
|
475
514
|
excludeEvents: [],
|
|
476
515
|
includeSchema: { [this.defaultSchema]: includedTables },
|
|
@@ -482,7 +521,7 @@ AND table_type = 'BASE TABLE';`,
|
|
|
482
521
|
// Forever young
|
|
483
522
|
await new Promise<void>((resolve, reject) => {
|
|
484
523
|
zongji.on('error', (error) => {
|
|
485
|
-
logger.error('
|
|
524
|
+
logger.error('Binlog listener error:', error);
|
|
486
525
|
zongji.stop();
|
|
487
526
|
queue.kill();
|
|
488
527
|
reject(error);
|
|
@@ -578,7 +617,7 @@ AND table_type = 'BASE TABLE';`,
|
|
|
578
617
|
beforeReplicaId: beforeUpdated
|
|
579
618
|
? getUuidReplicaIdentityBson(beforeUpdated, payload.sourceTable.replicaIdColumns)
|
|
580
619
|
: undefined,
|
|
581
|
-
after:
|
|
620
|
+
after: after,
|
|
582
621
|
afterReplicaId: getUuidReplicaIdentityBson(after, payload.sourceTable.replicaIdColumns)
|
|
583
622
|
});
|
|
584
623
|
|
|
@@ -62,7 +62,14 @@ export class MySQLConnectionManager {
|
|
|
62
62
|
* @param params
|
|
63
63
|
*/
|
|
64
64
|
async query(query: string, params?: any[]): Promise<[RowDataPacket[], FieldPacket[]]> {
|
|
65
|
-
|
|
65
|
+
let connection: mysqlPromise.PoolConnection | undefined;
|
|
66
|
+
try {
|
|
67
|
+
connection = await this.promisePool.getConnection();
|
|
68
|
+
await connection.query(`SET time_zone = '+00:00'`);
|
|
69
|
+
return connection.query<RowDataPacket[]>(query, params);
|
|
70
|
+
} finally {
|
|
71
|
+
connection?.release();
|
|
72
|
+
}
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
/**
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
declare module '@powersync/mysql-zongji' {
|
|
2
|
+
import { Socket } from 'net';
|
|
3
|
+
|
|
2
4
|
export type ZongjiOptions = {
|
|
3
5
|
host: string;
|
|
4
6
|
user: string;
|
|
@@ -108,7 +110,15 @@ declare module '@powersync/mysql-zongji' {
|
|
|
108
110
|
|
|
109
111
|
export type BinLogEvent = BinLogRotationEvent | BinLogGTIDLogEvent | BinLogXidEvent | BinLogMutationEvent;
|
|
110
112
|
|
|
113
|
+
// @vlasky/mysql Connection
|
|
114
|
+
export interface MySQLConnection {
|
|
115
|
+
_socket?: Socket;
|
|
116
|
+
/** There are other forms of this method as well - this is the most basic one. */
|
|
117
|
+
query(sql: string, callback: (error: any, results: any, fields: any) => void): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
111
120
|
export default class ZongJi {
|
|
121
|
+
connection: MySQLConnection;
|
|
112
122
|
constructor(options: ZongjiOptions);
|
|
113
123
|
|
|
114
124
|
start(options: StartOptions): void;
|
package/src/utils/mysql-utils.ts
CHANGED
|
@@ -3,6 +3,7 @@ import mysql from 'mysql2';
|
|
|
3
3
|
import mysqlPromise from 'mysql2/promise';
|
|
4
4
|
import * as types from '../types/types.js';
|
|
5
5
|
import { coerce, gte } from 'semver';
|
|
6
|
+
import { SourceTable } from '@powersync/service-core';
|
|
6
7
|
|
|
7
8
|
export type RetriedQueryOptions = {
|
|
8
9
|
connection: mysqlPromise.Connection;
|
|
@@ -82,3 +83,7 @@ export function isVersionAtLeast(version: string, minimumVersion: string): boole
|
|
|
82
83
|
|
|
83
84
|
return gte(coercedVersion!, coercedMinimumVersion!, { loose: true });
|
|
84
85
|
}
|
|
86
|
+
|
|
87
|
+
export function escapeMysqlTableName(table: SourceTable): string {
|
|
88
|
+
return `\`${table.schema.replaceAll('`', '``')}\`.\`${table.table.replaceAll('`', '``')}\``;
|
|
89
|
+
}
|