Skip to content

Commit 26240f4

Browse files
committed
fix(mysql): make sure migrations are run in order when run concurrently
Now we either lock all or none of the migrations to run, to make sure they are not out of order when multiple instances of Emigrate run concurrently.
1 parent 6eb6017 commit 26240f4

File tree

13 files changed

+922
-98
lines changed

13 files changed

+922
-98
lines changed

.changeset/bright-poems-approve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@emigrate/mysql': patch
3+
---
4+
5+
Make sure we can initialize multiple running instances of Emigrate using @emigrate/mysql concurrently without issues with creating the history table (for instance in a Kubernetes environment and/or with a Percona cluster).

.changeset/yellow-walls-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@emigrate/mysql': patch
3+
---
4+
5+
Either lock all or none of the migrations to run to make sure they run in order when multiple instances of Emigrate runs concurrently (for instance in a Kubernetes environment)

.github/workflows/ci.yaml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ jobs:
1515
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
1616
DO_NOT_TRACK: 1
1717

18+
services:
19+
mysql:
20+
image: mysql:8.0
21+
env:
22+
MYSQL_ROOT_PASSWORD: root
23+
MYSQL_DATABASE: emigrate
24+
MYSQL_USER: emigrate
25+
MYSQL_PASSWORD: emigrate
26+
ports:
27+
- 3306:3306
28+
options: --health-cmd="mysqladmin ping -h localhost" --health-interval=10s --health-timeout=5s --health-retries=5
29+
1830
steps:
1931
- name: Check out code
2032
uses: actions/checkout@v4
@@ -26,11 +38,22 @@ jobs:
2638
- name: Setup Node.js environment
2739
uses: actions/setup-node@v4
2840
with:
29-
node-version: 20.9.0
41+
node-version: 22.15.0
3042
cache: 'pnpm'
3143

3244
- name: Install dependencies
3345
run: pnpm install
3446

47+
- name: Wait for MySQL to be ready
48+
run: |
49+
for i in {1..30}; do
50+
nc -z localhost 3306 && echo "MySQL is up!" && break
51+
echo "Waiting for MySQL..."
52+
sleep 2
53+
done
54+
3555
- name: Checks
56+
env:
57+
MYSQL_HOST: localhost
58+
MYSQL_PORT: 3306
3659
run: pnpm checks

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- name: Setup Node.js environment
3131
uses: actions/setup-node@v4
3232
with:
33-
node-version: 20.9.0
33+
node-version: 22.15.0
3434
cache: 'pnpm'
3535

3636
- name: Install Dependencies

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"bugs": "https://github.com/aboviq/emigrate/issues",
3838
"license": "MIT",
3939
"volta": {
40-
"node": "20.9.0",
40+
"node": "22.15.0",
4141
"pnpm": "9.4.0"
4242
},
4343
"packageManager": "pnpm@9.4.0",
@@ -80,6 +80,7 @@
8080
"lint-staged": "15.2.0",
8181
"npm-run-all": "4.1.5",
8282
"prettier": "3.1.1",
83+
"testcontainers": "10.24.2",
8384
"tsx": "4.15.7",
8485
"turbo": "2.0.5",
8586
"typescript": "5.5.2",

packages/cli/src/commands/remove.test.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
StorageInitError,
1212
} from '../errors.js';
1313
import {
14+
assertErrorEqualEnough,
1415
getErrorCause,
1516
getMockedReporter,
1617
getMockedStorage,
@@ -199,6 +200,11 @@ function assertPreconditionsFailed(reporter: Mocked<Required<EmigrateReporter>>,
199200
assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0, 'Total pending and skipped');
200201
assert.strictEqual(reporter.onFinished.mock.calls.length, 1, 'Finished called once');
201202
const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? [];
203+
// hackety hack:
204+
if (finishedError) {
205+
finishedError.stack = error?.stack;
206+
}
207+
202208
assert.deepStrictEqual(error, finishedError, 'Finished error');
203209
const cause = getErrorCause(error);
204210
const expectedCause = finishedError?.cause;
@@ -288,14 +294,7 @@ function assertPreconditionsFulfilled(
288294
assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0, 'Total pending and skipped');
289295
assert.strictEqual(reporter.onFinished.mock.calls.length, 1, 'Finished called once');
290296
const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? [];
291-
assert.deepStrictEqual(error, finishedError, 'Finished error');
292-
const cause = getErrorCause(error);
293-
const expectedCause = finishedError?.cause;
294-
assert.deepStrictEqual(
295-
cause,
296-
expectedCause ? deserializeError(expectedCause) : expectedCause,
297-
'Finished error cause',
298-
);
297+
assertErrorEqualEnough(error, finishedError, 'Finished error');
299298
assert.strictEqual(entries?.length, expected.length, 'Finished entries length');
300299
assert.deepStrictEqual(
301300
entries.map((entry) => `${entry.name} (${entry.status})`),

packages/cli/src/commands/up.test.ts

Lines changed: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
import { describe, it, mock } from 'node:test';
22
import assert from 'node:assert';
3-
import {
4-
type EmigrateReporter,
5-
type Storage,
6-
type Plugin,
7-
type SerializedError,
8-
type MigrationMetadataFinished,
9-
} from '@emigrate/types';
10-
import { deserializeError } from 'serialize-error';
3+
import { type EmigrateReporter, type Storage, type Plugin, type MigrationMetadataFinished } from '@emigrate/types';
114
import { version } from '../get-package-info.js';
125
import {
136
BadOptionError,
@@ -16,15 +9,14 @@ import {
169
MigrationHistoryError,
1710
MigrationRunError,
1811
StorageInitError,
19-
toSerializedError,
2012
} from '../errors.js';
2113
import {
2214
type Mocked,
2315
toEntry,
2416
toMigrations,
2517
getMockedReporter,
2618
getMockedStorage,
27-
getErrorCause,
19+
assertErrorEqualEnough,
2820
} from '../test-utils.js';
2921
import upCommand from './up.js';
3022

@@ -930,31 +922,21 @@ function assertPreconditionsFulfilled(
930922
for (const [index, entry] of failedEntries.entries()) {
931923
if (entry.status === 'failed') {
932924
const error = reporter.onMigrationError.mock.calls[index]?.arguments[1];
933-
assert.deepStrictEqual(error, entry.error, 'Error');
934-
const cause = entry.error?.cause;
935-
assert.deepStrictEqual(error?.cause, cause ? deserializeError(cause) : cause, 'Error cause');
925+
assertErrorEqualEnough(error, entry.error, 'Error');
936926

937927
if (entry.started) {
938928
const [finishedMigration, error] = storage.onError.mock.calls[index]?.arguments ?? [];
939929
assert.strictEqual(finishedMigration?.name, entry.name);
940930
assert.strictEqual(finishedMigration?.status, entry.status);
941-
assertErrorEqualEnough(error, entry.error);
931+
assertErrorEqualEnough(error, entry.error, `Entry error (${entry.name})`);
942932
}
943933
}
944934
}
945935

946936
assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, pending + skipped, 'Total pending and skipped');
947937
assert.strictEqual(reporter.onFinished.mock.calls.length, 1, 'Finished called once');
948938
const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? [];
949-
assertErrorEqualEnough(error, finishedError);
950-
951-
const cause = getErrorCause(error);
952-
const expectedCause = finishedError?.cause;
953-
assert.deepStrictEqual(
954-
cause,
955-
expectedCause ? deserializeError(expectedCause) : expectedCause,
956-
'Finished error cause',
957-
);
939+
assertErrorEqualEnough(error, finishedError, 'Finished error');
958940
assert.strictEqual(entries?.length, expected.length, 'Finished entries length');
959941
assert.deepStrictEqual(
960942
entries.map((entry) => `${entry.name} (${entry.status})`),
@@ -995,33 +977,6 @@ function assertPreconditionsFailed(
995977
assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0, 'Total pending and skipped');
996978
assert.strictEqual(reporter.onFinished.mock.calls.length, 1, 'Finished called once');
997979
const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? [];
998-
assert.deepStrictEqual(error, finishedError, 'Finished error');
999-
const cause = getErrorCause(error);
1000-
const expectedCause = finishedError?.cause;
1001-
assert.deepStrictEqual(
1002-
cause,
1003-
expectedCause ? deserializeError(expectedCause) : expectedCause,
1004-
'Finished error cause',
1005-
);
980+
assertErrorEqualEnough(error, finishedError, 'Finished error');
1006981
assert.strictEqual(entries?.length, 0, 'Finished entries length');
1007982
}
1008-
1009-
function assertErrorEqualEnough(actual?: Error | SerializedError, expected?: Error) {
1010-
if (expected === undefined) {
1011-
assert.strictEqual(actual, undefined);
1012-
return;
1013-
}
1014-
1015-
const {
1016-
cause: actualCause,
1017-
stack: actualStack,
1018-
...actualError
1019-
} = actual instanceof Error ? toSerializedError(actual) : actual ?? {};
1020-
const { cause: expectedCause, stack: expectedStack, ...expectedError } = toSerializedError(expected);
1021-
// @ts-expect-error Ignore
1022-
const { stack: actualCauseStack, ...actualCauseRest } = actualCause ?? {};
1023-
// @ts-expect-error Ignore
1024-
const { stack: expectedCauseStack, ...expectedCauseRest } = expectedCause ?? {};
1025-
assert.deepStrictEqual(actualError, expectedError);
1026-
assert.deepStrictEqual(actualCauseRest, expectedCauseRest);
1027-
}

packages/cli/src/test-utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { mock, type Mock } from 'node:test';
22
import path from 'node:path';
3+
import assert from 'node:assert';
34
import {
45
type SerializedError,
56
type EmigrateReporter,
@@ -9,6 +10,7 @@ import {
910
type NonFailedMigrationHistoryEntry,
1011
type Storage,
1112
} from '@emigrate/types';
13+
import { toSerializedError } from './errors.js';
1214

1315
export type Mocked<T> = {
1416
// @ts-expect-error - This is a mock
@@ -110,3 +112,23 @@ export function toEntries(
110112
): MigrationHistoryEntry[] {
111113
return names.map((name) => (typeof name === 'string' ? toEntry(name, status) : name));
112114
}
115+
116+
export function assertErrorEqualEnough(actual?: Error | SerializedError, expected?: Error, message?: string): void {
117+
if (expected === undefined) {
118+
assert.strictEqual(actual, undefined);
119+
return;
120+
}
121+
122+
const {
123+
cause: actualCause,
124+
stack: actualStack,
125+
...actualError
126+
} = actual instanceof Error ? toSerializedError(actual) : actual ?? {};
127+
const { cause: expectedCause, stack: expectedStack, ...expectedError } = toSerializedError(expected);
128+
// @ts-expect-error Ignore
129+
const { stack: actualCauseStack, ...actualCauseRest } = actualCause ?? {};
130+
// @ts-expect-error Ignore
131+
const { stack: expectedCauseStack, ...expectedCauseRest } = expectedCause ?? {};
132+
assert.deepStrictEqual(actualError, expectedError, message);
133+
assert.deepStrictEqual(actualCauseRest, expectedCauseRest, message ? `${message} (cause)` : undefined);
134+
}

packages/mysql/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@
1717
},
1818
"files": [
1919
"dist",
20-
"!dist/*.tsbuildinfo"
20+
"!dist/*.tsbuildinfo",
21+
"!dist/**/*.test.js",
22+
"!dist/tests/*"
2123
],
2224
"scripts": {
2325
"build": "tsc --pretty",
2426
"build:watch": "tsc --pretty --watch",
25-
"lint": "xo --cwd=../.. $(pwd)"
27+
"lint": "xo --cwd=../.. $(pwd)",
28+
"test-disabled": "glob -c \"node --import tsx --test-reporter spec --test\" \"./src/**/*.test.ts\"",
29+
"test:watch": "glob -c \"node --watch --import tsx --test-reporter spec --test\" \"./src/**/*.test.ts\""
2630
},
2731
"keywords": [
2832
"emigrate",

packages/mysql/src/index.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import assert from 'node:assert';
2+
import path from 'node:path';
3+
import { before, after, describe, it } from 'node:test';
4+
import type { MigrationMetadata } from '@emigrate/types';
5+
import { startDatabase, stopDatabase } from './tests/database.js';
6+
import { createMysqlStorage } from './index.js';
7+
8+
let db: { port: number; host: string };
9+
10+
describe('emigrate-mysql', async () => {
11+
before(
12+
async () => {
13+
db = await startDatabase();
14+
},
15+
{ timeout: 60_000 },
16+
);
17+
18+
after(
19+
async () => {
20+
await stopDatabase();
21+
},
22+
{ timeout: 10_000 },
23+
);
24+
25+
describe('migration locks', async () => {
26+
it('either locks none or all of the given migrations', async () => {
27+
const { initializeStorage } = createMysqlStorage({
28+
table: 'migrations',
29+
connection: {
30+
host: db.host,
31+
user: 'emigrate',
32+
password: 'emigrate',
33+
database: 'emigrate',
34+
port: db.port,
35+
},
36+
});
37+
38+
const [storage1, storage2] = await Promise.all([initializeStorage(), initializeStorage()]);
39+
40+
const migrations = toMigrations('/emigrate', 'migrations', [
41+
'2023-10-01-01-test.js',
42+
'2023-10-01-02-test.js',
43+
'2023-10-01-03-test.js',
44+
'2023-10-01-04-test.js',
45+
'2023-10-01-05-test.js',
46+
'2023-10-01-06-test.js',
47+
'2023-10-01-07-test.js',
48+
'2023-10-01-08-test.js',
49+
'2023-10-01-09-test.js',
50+
'2023-10-01-10-test.js',
51+
'2023-10-01-11-test.js',
52+
'2023-10-01-12-test.js',
53+
'2023-10-01-13-test.js',
54+
'2023-10-01-14-test.js',
55+
'2023-10-01-15-test.js',
56+
'2023-10-01-16-test.js',
57+
'2023-10-01-17-test.js',
58+
'2023-10-01-18-test.js',
59+
'2023-10-01-19-test.js',
60+
'2023-10-01-20-test.js',
61+
]);
62+
63+
const [locked1, locked2] = await Promise.all([storage1.lock(migrations), storage2.lock(migrations)]);
64+
65+
assert.strictEqual(
66+
locked1.length === 0 || locked2.length === 0,
67+
true,
68+
'One of the processes should have no locks',
69+
);
70+
assert.strictEqual(
71+
locked1.length === 20 || locked2.length === 20,
72+
true,
73+
'One of the processes should have all locks',
74+
);
75+
});
76+
});
77+
});
78+
79+
function toMigration(cwd: string, directory: string, name: string): MigrationMetadata {
80+
return {
81+
name,
82+
filePath: `${cwd}/${directory}/${name}`,
83+
relativeFilePath: `${directory}/${name}`,
84+
extension: path.extname(name),
85+
directory,
86+
cwd,
87+
};
88+
}
89+
90+
function toMigrations(cwd: string, directory: string, names: string[]): MigrationMetadata[] {
91+
return names.map((name) => toMigration(cwd, directory, name));
92+
}

0 commit comments

Comments
 (0)