@powersync/service-module-mysql 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214
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 +134 -4
- package/dist/api/MySQLRouteAPIAdapter.d.ts +3 -3
- package/dist/api/MySQLRouteAPIAdapter.js +6 -5
- 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/common/schema-utils.js +4 -0
- package/dist/common/schema-utils.js.map +1 -1
- package/dist/replication/BinLogReplicationJob.d.ts +1 -1
- package/dist/replication/BinLogReplicationJob.js +6 -4
- package/dist/replication/BinLogReplicationJob.js.map +1 -1
- package/dist/replication/BinLogReplicator.js +1 -1
- package/dist/replication/BinLogReplicator.js.map +1 -1
- package/dist/replication/BinLogStream.d.ts +8 -0
- package/dist/replication/BinLogStream.js +62 -22
- 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 +12 -22
- package/src/api/MySQLRouteAPIAdapter.ts +10 -9
- 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/common/schema-utils.ts +8 -0
- package/src/replication/BinLogReplicationJob.ts +6 -4
- package/src/replication/BinLogReplicator.ts +1 -1
- package/src/replication/BinLogStream.ts +67 -22
- 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/BinlogStreamUtils.ts +23 -23
- 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.json +0 -3
- package/tsconfig.tsbuildinfo +1 -1
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`);
|
|
@@ -33,7 +33,7 @@ export class BinlogStreamTestContext {
|
|
|
33
33
|
private streamPromise?: Promise<void>;
|
|
34
34
|
public storage?: SyncRulesBucketStorage;
|
|
35
35
|
private replicationDone = false;
|
|
36
|
-
private syncRulesContent?: storage.
|
|
36
|
+
private syncRulesContent?: storage.PersistedSyncConfigContent;
|
|
37
37
|
|
|
38
38
|
static async open(factory: storage.TestStorageFactory, options?: { doNotClear?: boolean }) {
|
|
39
39
|
const f = await factory({ doNotClear: options?.doNotClear });
|
|
@@ -71,38 +71,38 @@ export class BinlogStreamTestContext {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
async updateSyncRules(content: string): Promise<SyncRulesBucketStorage> {
|
|
74
|
-
const
|
|
74
|
+
const replicationStream = await this.factory.updateSyncRules(
|
|
75
75
|
updateSyncRulesFromYaml(content, { validate: true, storageVersion: LEGACY_STORAGE_VERSION })
|
|
76
76
|
);
|
|
77
|
-
this.syncRulesContent =
|
|
78
|
-
this.storage = this.factory.getInstance(
|
|
77
|
+
this.syncRulesContent = replicationStream.syncConfigContent[0];
|
|
78
|
+
this.storage = this.factory.getInstance(replicationStream);
|
|
79
79
|
return this.storage!;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
async loadNextSyncRules() {
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
83
|
+
const syncConfig = await this.factory.getDeployingSyncConfig();
|
|
84
|
+
if (syncConfig == null) {
|
|
85
85
|
throw new Error(`Next replication stream not available`);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
this.syncRulesContent =
|
|
89
|
-
this.storage =
|
|
88
|
+
this.syncRulesContent = syncConfig.content;
|
|
89
|
+
this.storage = syncConfig.storage;
|
|
90
90
|
return this.storage!;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
async loadActiveSyncRules() {
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
94
|
+
const syncConfig = await this.factory.getActiveSyncConfig();
|
|
95
|
+
if (syncConfig == null) {
|
|
96
96
|
throw new Error(`Active replication stream not available`);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
this.syncRulesContent =
|
|
100
|
-
this.storage =
|
|
99
|
+
this.syncRulesContent = syncConfig.content;
|
|
100
|
+
this.storage = syncConfig.storage;
|
|
101
101
|
this.replicationDone = true;
|
|
102
102
|
return this.storage!;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
private
|
|
105
|
+
private getSyncConfigContent(): storage.PersistedSyncConfigContent {
|
|
106
106
|
if (this.syncRulesContent == null) {
|
|
107
107
|
throw new Error('Sync config not configured - call updateSyncRules() first');
|
|
108
108
|
}
|
|
@@ -148,7 +148,7 @@ export class BinlogStreamTestContext {
|
|
|
148
148
|
});
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
async getCheckpoint(options?: { timeout?: number }): Promise<
|
|
151
|
+
async getCheckpoint(options?: { timeout?: number }): Promise<ReplicationCheckpoint> {
|
|
152
152
|
const connection = await this.connectionManager.getConnection();
|
|
153
153
|
let checkpoint = await Promise.race([
|
|
154
154
|
getClientCheckpoint(connection, this.factory, { timeout: options?.timeout ?? 60_000 }),
|
|
@@ -165,9 +165,9 @@ export class BinlogStreamTestContext {
|
|
|
165
165
|
|
|
166
166
|
async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
|
|
167
167
|
const checkpoint = await this.getCheckpoint(options);
|
|
168
|
-
const
|
|
169
|
-
const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(
|
|
170
|
-
return test_utils.
|
|
168
|
+
const syncConfigContent = this.getSyncConfigContent();
|
|
169
|
+
const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncConfigContent, bucket, start));
|
|
170
|
+
return test_utils.getBatchArray(this.storage!.getBucketDataBatch(checkpoint, map));
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
async getBucketData(
|
|
@@ -179,11 +179,11 @@ export class BinlogStreamTestContext {
|
|
|
179
179
|
if (typeof start == 'string') {
|
|
180
180
|
start = BigInt(start);
|
|
181
181
|
}
|
|
182
|
-
const
|
|
182
|
+
const syncConfigContent = this.getSyncConfigContent();
|
|
183
183
|
const checkpoint = await this.getCheckpoint(options);
|
|
184
|
-
const map = [bucketRequest(
|
|
184
|
+
const map = [bucketRequest(syncConfigContent, bucket, start)];
|
|
185
185
|
const batch = this.storage!.getBucketDataBatch(checkpoint, map);
|
|
186
|
-
const batches = await test_utils.
|
|
186
|
+
const batches = await test_utils.getBatchArray(batch);
|
|
187
187
|
return batches[0]?.chunkData.data ?? [];
|
|
188
188
|
}
|
|
189
189
|
}
|
|
@@ -192,7 +192,7 @@ export async function getClientCheckpoint(
|
|
|
192
192
|
connection: mysqlPromise.Connection,
|
|
193
193
|
storageFactory: BucketStorageFactory,
|
|
194
194
|
options?: { timeout?: number }
|
|
195
|
-
): Promise<
|
|
195
|
+
): Promise<ReplicationCheckpoint> {
|
|
196
196
|
const start = Date.now();
|
|
197
197
|
const gtid = await readExecutedGtid(connection);
|
|
198
198
|
// This old API needs a persisted checkpoint id.
|
|
@@ -203,14 +203,14 @@ export async function getClientCheckpoint(
|
|
|
203
203
|
|
|
204
204
|
logger.info('Expected Checkpoint: ' + gtid.comparable);
|
|
205
205
|
while (Date.now() - start < timeout) {
|
|
206
|
-
const storage = await storageFactory.
|
|
206
|
+
const storage = (await storageFactory.getActiveSyncConfig())?.storage;
|
|
207
207
|
const cp = await storage?.getCheckpoint();
|
|
208
208
|
if (cp == null) {
|
|
209
209
|
throw new Error('No replication stream available');
|
|
210
210
|
}
|
|
211
211
|
lastCp = cp;
|
|
212
212
|
if (cp.lsn && cp.lsn >= gtid.comparable) {
|
|
213
|
-
return cp
|
|
213
|
+
return cp;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ReplicatedGTID } from '@module/common/ReplicatedGTID.js';
|
|
2
|
+
import * as uuid from 'uuid';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
|
|
5
|
+
describe('ReplicatedGTID', () => {
|
|
6
|
+
const SERVER_UUID = 'a7d0ff7b-0c0e-11f0-8b38-566fbaa00004';
|
|
7
|
+
const POSITION = { filename: 'binlog.000042', offset: 1234 };
|
|
8
|
+
|
|
9
|
+
describe('single GTID', () => {
|
|
10
|
+
test('exposes its raw value, server UUID, and binlog position', () => {
|
|
11
|
+
const gtid = new ReplicatedGTID({
|
|
12
|
+
rawGtid: `${SERVER_UUID}:5`,
|
|
13
|
+
position: POSITION
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(gtid.raw).toEqual(`${SERVER_UUID}:5`);
|
|
17
|
+
expect(gtid.serverUuid).toEqual(SERVER_UUID);
|
|
18
|
+
expect(gtid.position).toEqual(POSITION);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('formats a comparable LSN using the transaction id', () => {
|
|
22
|
+
const gtid = new ReplicatedGTID({
|
|
23
|
+
rawGtid: `${SERVER_UUID}:17`,
|
|
24
|
+
position: POSITION
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(gtid.comparable).toEqual(`0000000000000017|${SERVER_UUID}:17|binlog.000042|1234`);
|
|
28
|
+
expect(gtid.toString()).toEqual(gtid.comparable);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('normalizes surrounding whitespace', () => {
|
|
32
|
+
const gtid = new ReplicatedGTID({
|
|
33
|
+
rawGtid: ` \n\t${SERVER_UUID}:17 \r\n`,
|
|
34
|
+
position: POSITION
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(gtid.raw).toEqual(`${SERVER_UUID}:17`);
|
|
38
|
+
expect(gtid.serverUuid).toEqual(SERVER_UUID);
|
|
39
|
+
expect(gtid.comparable).toEqual(`0000000000000017|${SERVER_UUID}:17|binlog.000042|1234`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('keeps the ZERO GTID format stable', () => {
|
|
43
|
+
expect(ReplicatedGTID.ZERO(SERVER_UUID).raw).toEqual(`${SERVER_UUID}:0`);
|
|
44
|
+
expect(ReplicatedGTID.ZERO(SERVER_UUID).comparable).toEqual(`0000000000000000|${SERVER_UUID}:0||0`);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('validation', () => {
|
|
49
|
+
test.each([
|
|
50
|
+
['', 'missing server UUID and transaction id'],
|
|
51
|
+
[SERVER_UUID, 'missing transaction id'],
|
|
52
|
+
[`${SERVER_UUID}:`, 'empty transaction id'],
|
|
53
|
+
[`:${17}`, 'empty server UUID'],
|
|
54
|
+
[`${SERVER_UUID}:1-17`, 'transaction interval'],
|
|
55
|
+
[`${SERVER_UUID}:1:17`, 'multiple transaction components'],
|
|
56
|
+
[`${SERVER_UUID}:abc`, 'non-numeric transaction id'],
|
|
57
|
+
[`${SERVER_UUID}:-1`, 'negative transaction id'],
|
|
58
|
+
[`${SERVER_UUID}:17,another-server:9`, 'comma-separated GTID set'],
|
|
59
|
+
[`${SERVER_UUID}:17,\nanother-server:9`, 'newline-separated GTID set']
|
|
60
|
+
])('rejects %s (%s)', (rawGtid) => {
|
|
61
|
+
expect(() => new ReplicatedGTID({ rawGtid, position: POSITION })).toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('serialization', () => {
|
|
66
|
+
test('round-trips a single GTID', () => {
|
|
67
|
+
const gtid = new ReplicatedGTID({
|
|
68
|
+
rawGtid: `${SERVER_UUID}:17`,
|
|
69
|
+
position: POSITION
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const deserialized = ReplicatedGTID.fromSerialized(gtid.comparable);
|
|
73
|
+
|
|
74
|
+
expect(deserialized.raw).toEqual(gtid.raw);
|
|
75
|
+
expect(deserialized.serverUuid).toEqual(SERVER_UUID);
|
|
76
|
+
expect(deserialized.position).toEqual(POSITION);
|
|
77
|
+
expect(deserialized.comparable).toEqual(gtid.comparable);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('throws on malformed serialized GTIDs', () => {
|
|
81
|
+
expect(() => ReplicatedGTID.fromSerialized('abc')).toThrow('Invalid serialized GTID');
|
|
82
|
+
expect(() => ReplicatedGTID.fromSerialized(`0000000000000001|${SERVER_UUID}:1|binlog.000001`)).toThrow(
|
|
83
|
+
'Invalid serialized GTID'
|
|
84
|
+
);
|
|
85
|
+
expect(() => ReplicatedGTID.fromSerialized(`0000000000000001|${SERVER_UUID}:1|binlog.000001|notanumber`)).toThrow(
|
|
86
|
+
'Invalid BinLog offset'
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('rejects a serialized GTID set', () => {
|
|
91
|
+
const serialized = `0000000000000017|${SERVER_UUID}:1-17|binlog.000042|1234`;
|
|
92
|
+
|
|
93
|
+
expect(() => ReplicatedGTID.fromSerialized(serialized)).toThrow('Expected a single transaction id');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('binlog events', () => {
|
|
98
|
+
test('creates a single GTID from a binlog event', () => {
|
|
99
|
+
const gtid = ReplicatedGTID.fromBinLogEvent({
|
|
100
|
+
rawGtid: {
|
|
101
|
+
serverUuid: Buffer.from(uuid.parse(SERVER_UUID)),
|
|
102
|
+
transactionId: 17
|
|
103
|
+
},
|
|
104
|
+
position: POSITION
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(gtid.raw).toEqual(`${SERVER_UUID}:17`);
|
|
108
|
+
expect(gtid.position).toEqual(POSITION);
|
|
109
|
+
expect(gtid.comparable).toEqual(`0000000000000017|${SERVER_UUID}:17|binlog.000042|1234`);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('LSN ordering', () => {
|
|
114
|
+
test('orders GTIDs from the same server by transaction id', () => {
|
|
115
|
+
const earlier = new ReplicatedGTID({ rawGtid: `${SERVER_UUID}:9`, position: POSITION });
|
|
116
|
+
const later = new ReplicatedGTID({ rawGtid: `${SERVER_UUID}:18`, position: POSITION });
|
|
117
|
+
|
|
118
|
+
expect(earlier.comparable < later.comparable).toBeTruthy();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('orders LSNs for the same transaction by binlog offset', () => {
|
|
122
|
+
// The binlog offset is not zero-padded, so lexicographic ordering only holds for
|
|
123
|
+
// offsets with the same number of digits. This format cannot change while existing
|
|
124
|
+
// LSNs remain persisted in bucket storage.
|
|
125
|
+
const rawGtid = `${SERVER_UUID}:18`;
|
|
126
|
+
const transactionStart = new ReplicatedGTID({
|
|
127
|
+
rawGtid,
|
|
128
|
+
position: { filename: 'binlog.000042', offset: 157 }
|
|
129
|
+
});
|
|
130
|
+
const transactionEnd = new ReplicatedGTID({
|
|
131
|
+
rawGtid,
|
|
132
|
+
position: { filename: 'binlog.000042', offset: 300 }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(transactionStart.comparable < transactionEnd.comparable).toBeTruthy();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { checkSourceConfiguration } from '@module/common/check-source-configuration.js';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { createMockMySQLConnection } from './util.js';
|
|
4
|
+
|
|
5
|
+
describe('checkSourceConfiguration', () => {
|
|
6
|
+
test('accepts a primary MySQL server', async () => {
|
|
7
|
+
const { connection, query } = createConnection({ version: '8.4.0', replicaStatuses: [] });
|
|
8
|
+
|
|
9
|
+
await expect(checkSourceConfiguration(connection)).resolves.toEqual([]);
|
|
10
|
+
expect(query).toHaveBeenCalledWith('SHOW REPLICA STATUS', []);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('rejects a replica on MySQL 8.0.22 and later', async () => {
|
|
14
|
+
const { connection, query } = createConnection({
|
|
15
|
+
version: '8.0.22',
|
|
16
|
+
replicaStatuses: [{ Channel_Name: '' }]
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
await expect(checkSourceConfiguration(connection)).resolves.toContain(
|
|
20
|
+
'Connecting PowerSync to a MySQL replica is not supported. Please connect PowerSync directly to the primary server.'
|
|
21
|
+
);
|
|
22
|
+
expect(query).toHaveBeenCalledWith('SHOW REPLICA STATUS', []);
|
|
23
|
+
expect(query).not.toHaveBeenCalledWith('SHOW SLAVE STATUS', []);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('uses legacy replica-status syntax before MySQL 8.0.22', async () => {
|
|
27
|
+
const { connection, query } = createConnection({
|
|
28
|
+
version: '5.7.44',
|
|
29
|
+
replicaStatuses: [{ Channel_Name: '' }]
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await expect(checkSourceConfiguration(connection)).resolves.toContain(
|
|
33
|
+
'Connecting PowerSync to a MySQL replica is not supported. Please connect PowerSync directly to the primary server.'
|
|
34
|
+
);
|
|
35
|
+
expect(query).toHaveBeenCalledWith('SHOW SLAVE STATUS', []);
|
|
36
|
+
expect(query).not.toHaveBeenCalledWith('SHOW REPLICA STATUS', []);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function createConnection(options: { version: string; replicaStatuses: Record<string, unknown>[] }) {
|
|
40
|
+
return createMockMySQLConnection(async (sql) => {
|
|
41
|
+
switch (sql.trim()) {
|
|
42
|
+
case 'SELECT VERSION() as version':
|
|
43
|
+
return [[{ version: options.version }], []];
|
|
44
|
+
case 'SHOW REPLICA STATUS':
|
|
45
|
+
case 'SHOW SLAVE STATUS':
|
|
46
|
+
return [options.replicaStatuses, []];
|
|
47
|
+
case "SHOW VARIABLES LIKE 'binlog_format';":
|
|
48
|
+
return [[{ Value: 'ROW' }], []];
|
|
49
|
+
case "SHOW GLOBAL VARIABLES LIKE 'binlog_row_image';":
|
|
50
|
+
return [[{ Value: 'FULL' }], []];
|
|
51
|
+
default:
|
|
52
|
+
if (sql.includes('@@GLOBAL.gtid_mode AS gtid_mode')) {
|
|
53
|
+
return [
|
|
54
|
+
[
|
|
55
|
+
{
|
|
56
|
+
gtid_mode: 'ON',
|
|
57
|
+
log_bin: 1,
|
|
58
|
+
server_id: 1,
|
|
59
|
+
binlog_file: '/var/lib/mysql/binlog',
|
|
60
|
+
binlog_index_file: '/var/lib/mysql/binlog.index'
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
[]
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Unexpected query: ${sql}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
});
|