Skip to content

Commit fa2f524

Browse files
Merge pull request #230 from Sebastian-Webster/227-add-option-to-turn-mysql-x-off
Add option to turn MySQL X off
2 parents 2cc048a + c7010fa commit fa2f524

File tree

7 files changed

+208
-43
lines changed

7 files changed

+208
-43
lines changed

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,17 @@ npx mysql-memory-server@latest --version 8.4.x
7878
- `port: number`
7979
The port that the MySQL database is listening on
8080
- `xPort: number`
81-
The port that MySQLX is listening on
81+
The port that MySQLX is listening on. If MySQL X is turned off, or ```options.xEnabled``` is set to "ON" and the X Plugin fails to initialise, this value will be -1.
8282
- `dbName: string`
8383
The database that was created on database initialization
8484
- `username: string`
8585
The name of the user to use to login to the database
8686
- `socket: string`
8787
If on Windows, this is the name of the named pipe that MySQL is listening on. If not on Windows, this is the path to the socket that MySQL is listening on.
8888
- `xSocket: string`
89-
If on Windows, this is the name of the named pipe that the MySQL X Plugin is listening on. If not on Windows, this is the path that the MySQL X Plugin is listening on.
90-
- `mysql: {version: string, versionIsInstalledOnSystem: boolean}`
91-
An object with two properties. ```version``` is the version of MySQL used to create the database. ```versionIsInstalledOnSystem``` will be true if the MySQL version used is already installed on the system and false if the version had to be downloaded from MySQL's CDN.
89+
If on Windows, this is the name of the named pipe that the MySQL X Plugin is listening on. If not on Windows, this is the path that the MySQL X Plugin is listening on. If MySQL X is turned off, or ```options.xEnabled``` is set to "ON" and the X Plugin fails to initialise, this value will be an empty string.
90+
- `mysql: {version: string, versionIsInstalledOnSystem: boolean, xPluginIsEnabled: boolean}`
91+
An object with three properties. ```version``` is the version of MySQL used to create the database. ```versionIsInstalledOnSystem``` will be true if the MySQL version used is already installed on the system and false if the version had to be downloaded from MySQL's CDN. ```xPluginIsEnabled``` will be true if ```options.xEnabled``` is set to "FORCE", will be false if ```options.xEnabled``` is set to "OFF", and if ```options.xEnabled``` is set to "ON", this value will be true if the plugin initialised successfully, otherwise this value will be false.
9292
- `stop: () => Promise<void>`
9393
The method to stop the database. The returned promise resolves when the database has successfully stopped.
9494

@@ -189,3 +189,9 @@ The internal queries that are ran before the queries in ```initSQLString``` are
189189
Default: process.arch
190190

191191
Description: The MySQL binary architecture to execute. MySQL does not offer server builds for Windows on ARM, so to get this package working on Windows on ARM, set the arch option to "x64" and Windows will emulate MySQL.
192+
193+
- `xEnabled: "OFF" | "ON" | "FORCE"`
194+
195+
Default: "FORCE"
196+
197+
Description: This option follows the convention set out by the [MySQL Documentation](https://dev.mysql.com/doc/refman/en/plugin-loading.html). If set to "OFF", the MySQL X Plugin will not initialise. If set to "ON", the MySQL X Plugin will try to initialise, but if the initialisation process fails, the MySQL Server will continue the startup process with the plugin disabled. If set to "FORCE", the MySQL Server will not start up without a successful initialisation of the plugin. With this option set to "FORCE", the server will either start up with the plugin enabled, or the server will fail to start up.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
],
1818
"scripts": {
1919
"test": "jest --verbose --colors",
20-
"test:ci": "jest --setupFilesAfterEnv ./ciSetup.js --verbose --colors --runTestsByPath tests/concurrency.test.ts",
20+
"test:ci": "jest tests/ci --setupFilesAfterEnv ./ciSetup.js --verbose --colors",
2121
"os-compat:ci": "jest --setupFilesAfterEnv ./ciSetup.js --verbose --colors --runTestsByPath tests/versions.test.ts"
2222
},
2323
"engines": {

src/constants.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export const DEFAULT_OPTIONS: InternalServerOptions = {
1717
xPort: 0,
1818
downloadRetries: 10,
1919
initSQLString: '',
20-
arch: process.arch
20+
arch: process.arch,
21+
xEnabled: 'FORCE'
2122
} as const;
2223

2324
export const DEFAULT_OPTIONS_KEYS = Object.freeze(Object.keys(DEFAULT_OPTIONS))
@@ -41,6 +42,7 @@ export function getInternalEnvVariable(envVar: keyof typeof internalOptions): st
4142
}
4243

4344
const allowedArches = ['x64', 'arm64']
45+
const pluginActivationStates = ['OFF', 'ON', 'FORCE']
4446
export const OPTION_TYPE_CHECKS: OptionTypeChecks = {
4547
version: {
4648
check: (opt: any) => opt === undefined || typeof opt === 'string' && validSemver(coerceSemver(opt)) !== null,
@@ -111,6 +113,11 @@ export const OPTION_TYPE_CHECKS: OptionTypeChecks = {
111113
check: (opt: any) => opt === undefined || allowedArches.includes(opt),
112114
errorMessage: `Option arch must be either of the following: ${allowedArches.join(', ')}`,
113115
definedType: 'string'
116+
},
117+
xEnabled: {
118+
check: (opt: any) => opt === undefined || pluginActivationStates.includes(opt),
119+
errorMessage: `xEnabled must be either undefined or one of the following: ${pluginActivationStates.join(', ')}`,
120+
definedType: 'boolean'
114121
}
115122
} as const;
116123

src/libraries/Executor.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ChildProcess, execFile, spawn } from "child_process"
2-
import {coerce, gte, lt, satisfies} from 'semver';
2+
import {coerce, gte, lt, lte, satisfies} from 'semver';
33
import * as os from 'os'
44
import * as fsPromises from 'fs/promises';
55
import * as fs from 'fs';
@@ -63,11 +63,14 @@ class Executor {
6363
return null
6464
}
6565

66-
#startMySQLProcess(options: InternalServerOptions, port: number, mySQLXPort: number, datadir: string, dbPath: string, binaryFilepath: string): Promise<MySQLDB> {
66+
#startMySQLProcess(options: InternalServerOptions, datadir: string, dbPath: string, binaryFilepath: string): Promise<MySQLDB> {
6767
const errors: string[] = []
6868
const logFile = `${dbPath}/log.log`
6969
const errorLogFile = `${datadir}/errorlog.err`
7070

71+
const port = options.port || GenerateRandomPort()
72+
const mySQLXPort = options.xPort || GenerateRandomPort();
73+
7174
return new Promise(async (resolve, reject) => {
7275
await fsPromises.rm(logFile, {force: true})
7376

@@ -76,11 +79,9 @@ class Executor {
7679

7780
const mysqlArguments = [
7881
'--no-defaults',
79-
'--mysqlx=FORCE',
82+
`--mysqlx=${options.xEnabled}`,
8083
`--port=${port}`,
8184
`--datadir=${datadir}`,
82-
`--mysqlx-port=${mySQLXPort}`,
83-
`--mysqlx-socket=${xSocket}`,
8485
`--socket=${socket}`,
8586
`--general-log-file=${logFile}`,
8687
'--general-log=1',
@@ -91,23 +92,28 @@ class Executor {
9192
`--user=${os.userInfo().username}`
9293
]
9394

94-
//<8.0.11 does not have MySQL X turned on by default so we will be installing the X Plugin in this if statement.
95-
//MySQL 5.7.12 introduced the X plugin, but according to https://dev.mysql.com/doc/refman/5.7/en/document-store-setting-up.html, the database needs to be initialised with version 5.7.19.
96-
//If the MySQL version is >=5.7.19 & <8.0.11 then install the X Plugin
97-
if (lt(this.version, '8.0.11') && gte(this.version, '5.7.19')) {
98-
const pluginExtension = os.platform() === 'win32' ? 'dll' : 'so';
99-
let pluginPath: string;
100-
const firstPath = resolvePath(`${binaryFilepath}/../../lib/plugin`)
101-
const secondPath = '/usr/lib/mysql/plugin'
102-
103-
if (fs.existsSync(`${firstPath}/mysqlx.${pluginExtension}`)) {
104-
pluginPath = firstPath
105-
} else if (os.platform() === 'linux' && fs.existsSync(`${secondPath}/mysqlx.so`)) {
106-
pluginPath = secondPath
107-
} else {
108-
throw 'Could not install MySQL X as the path to the plugin cannot be found.'
95+
if (options.xEnabled !== 'OFF') {
96+
mysqlArguments.push(`--mysqlx-port=${mySQLXPort}`)
97+
mysqlArguments.push(`--mysqlx-socket=${xSocket}`)
98+
99+
//<8.0.11 does not have MySQL X turned on by default so we will be installing the X Plugin in this if statement.
100+
//MySQL 5.7.12 introduced the X plugin, but according to https://dev.mysql.com/doc/refman/5.7/en/document-store-setting-up.html, the database needs to be initialised with version 5.7.19.
101+
//If the MySQL version is >=5.7.19 & <8.0.11 then install the X Plugin
102+
if (lt(this.version, '8.0.11') && gte(this.version, '5.7.19')) {
103+
const pluginExtension = os.platform() === 'win32' ? 'dll' : 'so';
104+
let pluginPath: string;
105+
const firstPath = resolvePath(`${binaryFilepath}/../../lib/plugin`)
106+
const secondPath = '/usr/lib/mysql/plugin'
107+
108+
if (fs.existsSync(`${firstPath}/mysqlx.${pluginExtension}`)) {
109+
pluginPath = firstPath
110+
} else if (os.platform() === 'linux' && fs.existsSync(`${secondPath}/mysqlx.so`)) {
111+
pluginPath = secondPath
112+
} else {
113+
throw 'Could not install MySQL X as the path to the plugin cannot be found.'
114+
}
115+
mysqlArguments.splice(1, 0, `--plugin-dir=${pluginPath}`, `--early-plugin-load=mysqlx=mysqlx.${pluginExtension};`)
109116
}
110-
mysqlArguments.splice(1, 0, `--plugin-dir=${pluginPath}`, `--early-plugin-load=mysqlx=mysqlx.${pluginExtension};`)
111117
}
112118

113119
const process = spawn(binaryFilepath, mysqlArguments, {signal: this.DBDestroySignal.signal, killSignal: 'SIGKILL'})
@@ -219,17 +225,29 @@ class Executor {
219225
return //Promise rejection will be handled in the process.on('close') section because this.killedFromPortIssue is being set to true
220226
}
221227

228+
const xStartedSuccessfully = file.includes('X Plugin ready for connections') || file.includes("mysqlx reported: 'Server starts handling incoming connections'") || (lte(this.version, '8.0.12') && gte(this.version, '8.0.4') && file.search(/\[ERROR\].*Plugin mysqlx reported/m) === -1)
229+
230+
if (options.xEnabled === 'FORCE' && !xStartedSuccessfully) {
231+
this.logger.error('Error file:', file)
232+
this.logger.error('MySQL X failed to start successfully and xEnabled is set to "FORCE". Error log is above this message. If this is happening continually and you can start the database without the X Plugin, you can set options.xEnabled to "ON" or "OFF" instead of "FORCE".')
233+
const killed = await this.#killProcess(process)
234+
if (!killed) {
235+
this.logger.error('Failed to kill MySQL process after MySQL X failing to initialise.')
236+
}
237+
return reject('X Plugin failed to start and options.xEnabled is set to "FORCE".')
238+
}
222239

223-
resolve({
240+
const result: MySQLDB = {
224241
port,
225-
xPort: mySQLXPort,
242+
xPort: xStartedSuccessfully ? mySQLXPort : -1,
226243
socket,
227-
xSocket,
244+
xSocket: xStartedSuccessfully ? xSocket : '',
228245
dbName: options.dbName,
229246
username: options.username,
230247
mysql: {
231248
version: this.version,
232-
versionIsInstalledOnSystem: this.versionInstalledOnSystem
249+
versionIsInstalledOnSystem: this.versionInstalledOnSystem,
250+
xPluginIsEnabled: xStartedSuccessfully
233251
},
234252
stop: () => {
235253
return new Promise(async (resolve, reject) => {
@@ -244,7 +262,9 @@ class Executor {
244262
}
245263
})
246264
}
247-
})
265+
}
266+
267+
resolve(result)
248268
}
249269
}
250270
})
@@ -509,13 +529,9 @@ class Executor {
509529
await this.#setupDataDirectories(options, installedMySQLBinary, datadir, true);
510530
this.logger.log('Setting up directories was successful')
511531

512-
const port = options.port || GenerateRandomPort()
513-
const mySQLXPort = options.xPort || GenerateRandomPort();
514-
this.logger.log('Using port:', port, 'and MySQLX port:', mySQLXPort, 'on retry:', retries)
515-
516532
try {
517533
this.logger.log('Starting MySQL process')
518-
const resolved = await this.#startMySQLProcess(options, port, mySQLXPort, datadir, this.databasePath, installedMySQLBinary.path)
534+
const resolved = await this.#startMySQLProcess(options, datadir, this.databasePath, installedMySQLBinary.path)
519535
this.logger.log('Starting process was successful')
520536
return resolved
521537
} catch (e) {
@@ -526,7 +542,7 @@ class Executor {
526542
}
527543
retries++
528544
if (retries <= options.portRetries) {
529-
this.logger.warn(`One or both of these ports are already in use: ${port} or ${mySQLXPort}. Now retrying... ${retries}/${options.portRetries} possible retries.`)
545+
this.logger.warn(`Tried a port that is already in use. Now retrying... ${retries}/${options.portRetries} possible retries.`)
530546
} else {
531547
throw `The port has been retried ${options.portRetries} times and a free port could not be found.\nEither try again, or if this is a common issue, increase options.portRetries.`
532548
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import {expect, test, jest} from '@jest/globals'
2-
import { createDB } from '../src/index'
2+
import { createDB } from '../../src/index'
33
import sql from 'mysql2/promise'
44

55
jest.setTimeout(500_000);
66

77
const databaseCount = 3;
88

9+
const arch = process.arch === 'x64' || (process.platform === 'win32' && process.arch === 'arm64') ? 'x64' : 'arm64';
10+
911
test(`concurrency with ${databaseCount} simulataneous database creations`, async () => {
1012
const dbs = await Promise.all(
11-
Array.from(new Array(databaseCount)).map(() => createDB({logLevel: 'LOG'}))
13+
Array.from(new Array(databaseCount)).map(() => createDB({logLevel: 'LOG', arch}))
1214
)
1315

1416
for (const db of dbs) {

tests/ci/x.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {expect, test, jest} from '@jest/globals'
2+
import { createDB } from '../../src/index'
3+
import sql from 'mysql2/promise'
4+
import { ServerOptions } from '../../types';
5+
import http from 'http';
6+
7+
jest.setTimeout(500_000); //5 minutes
8+
9+
const arch = process.arch === 'x64' || (process.platform === 'win32' && process.arch === 'arm64') ? 'x64' : 'arm64';
10+
11+
test(`MySQL X is off when disabling it`, async () => {
12+
const options: ServerOptions = {
13+
arch,
14+
xEnabled: 'OFF',
15+
logLevel: 'LOG'
16+
}
17+
18+
const db = await createDB(options)
19+
const connection = await sql.createConnection({
20+
host: '127.0.0.1',
21+
user: db.username,
22+
port: db.port
23+
})
24+
25+
const plugins = JSON.stringify((await connection.query('SHOW PLUGINS;'))[0])
26+
console.log(plugins)
27+
const mysqlXDisabled = plugins.includes('"Name":"mysqlx","Status":"DISABLED"')
28+
29+
await connection.end();
30+
await db.stop();
31+
32+
expect(mysqlXDisabled).toBe(true)
33+
expect(db.xPort).toBe(-1)
34+
expect(db.xSocket).toBe('')
35+
})
36+
37+
test(`MySQL X is on when enabling it`, async () => {
38+
const options: ServerOptions = {
39+
arch,
40+
xEnabled: 'ON',
41+
logLevel: 'LOG'
42+
}
43+
44+
const db = await createDB(options)
45+
const connection = await sql.createConnection({
46+
host: '127.0.0.1',
47+
user: db.username,
48+
port: db.port
49+
})
50+
51+
const plugins = JSON.stringify((await connection.query('SHOW PLUGINS;'))[0])
52+
console.log(plugins)
53+
const mysqlXEnabled = plugins.includes('"Name":"mysqlx","Status":"ACTIVE"')
54+
55+
await connection.end();
56+
await db.stop();
57+
58+
expect(mysqlXEnabled).toBe(true)
59+
expect(db.xPort).toBeGreaterThan(0)
60+
expect(db.xPort).toBeLessThanOrEqual(65535)
61+
expect(typeof db.xSocket).toBe('string')
62+
})
63+
64+
test(`MySQL X is on when force enabling it`, async () => {
65+
const options: ServerOptions = {
66+
arch,
67+
xEnabled: 'FORCE',
68+
logLevel: 'LOG'
69+
}
70+
71+
const db = await createDB(options)
72+
const connection = await sql.createConnection({
73+
host: '127.0.0.1',
74+
user: db.username,
75+
port: db.port
76+
})
77+
78+
const plugins = JSON.stringify((await connection.query('SHOW PLUGINS;'))[0])
79+
console.log(plugins)
80+
const mysqlXEnabled = plugins.includes('"Name":"mysqlx","Status":"ACTIVE"')
81+
82+
await connection.end();
83+
await db.stop();
84+
85+
expect(mysqlXEnabled).toBe(true)
86+
expect(db.xPort).toBeGreaterThan(0)
87+
expect(db.xPort).toBeLessThanOrEqual(65535)
88+
expect(typeof db.xSocket).toBe('string')
89+
})
90+
91+
test('DB creation throws when MySQL fails to initialise and X is force enabled', async () => {
92+
const server: http.Server = await new Promise(resolve => {
93+
const httpServer = new http.Server();
94+
httpServer.listen(0, () => {
95+
resolve(httpServer)
96+
})
97+
})
98+
99+
const serverAddress = server.address();
100+
if (typeof serverAddress === 'string') {
101+
throw 'serverAddress is a string. Should be an object'
102+
}
103+
if (serverAddress === null) {
104+
throw 'serverAddress is null. Should be an object.'
105+
}
106+
107+
const options: ServerOptions = {
108+
arch,
109+
logLevel: 'LOG',
110+
xPort: serverAddress.port, // Use a port that is already in use to get X to fail
111+
xEnabled: 'FORCE',
112+
initSQLString: 'SELECT 2+2;'
113+
}
114+
115+
let thrown: string | boolean = false;
116+
117+
try {
118+
await createDB(options)
119+
} catch (e) {
120+
thrown = e
121+
}
122+
123+
await new Promise(resolve => {
124+
server.on('close', resolve)
125+
server.close()
126+
})
127+
128+
expect(thrown).toBe('The port has been retried 10 times and a free port could not be found.\nEither try again, or if this is a common issue, increase options.portRetries.')
129+
})

0 commit comments

Comments
 (0)