@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.
@@ -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
- let columns: Map<string, ColumnDescriptor>;
295
- return new Promise<void>((resolve, reject) => {
296
- // MAX_EXECUTION_TIME(0) hint disables execution timeout for this query
297
- connection
298
- .query(`SELECT /*+ MAX_EXECUTION_TIME(0) */ * FROM ${table.schema}.${table.table}`)
299
- .on('error', (err) => {
300
- reject(err);
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
- logger.info(`Reading binlog from: ${binLogPositionState.filename}:${binLogPositionState.offset}`);
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('Error on Binlog listener:', 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: common.toSQLiteRow(payload.data, payload.columns),
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
- return this.promisePool.query<RowDataPacket[]>(query, params);
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;
@@ -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
+ }